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.
This commit is contained in:
SkateSpots 2026-10-01 15:34:43 +07:00
parent b1d21f89ba
commit 807037ee9b
13 changed files with 658 additions and 3 deletions

1
.gitignore vendored
View file

@ -55,6 +55,7 @@ stats.json
/resources/ /resources/
/bun.lock /bun.lock
cvm-server/bun.lock cvm-server/bun.lock
push-service/bun.lock
# CVM server local dev manifest — the tracked manifest is package.cvm.json # CVM server local dev manifest — the tracked manifest is package.cvm.json
# (DEPLOY.md does `cp package.cvm.json package.json` on the server). # (DEPLOY.md does `cp package.cvm.json package.json` on the server).
cvm-server/package.json cvm-server/package.json

View file

@ -674,6 +674,28 @@ session with **no other memory of this project**. So:
(2026-09-29): HOLD at Phase 1 + 1b** — the zero-server design stands and (2026-09-29): HOLD at Phase 1 + 1b** — the zero-server design stands and
closed-app DMs/crew are a documented limitation, revisited only if it becomes closed-app DMs/crew are a documented limitation, revisited only if it becomes
load-bearing. `useResolvedDmRelays` is exported for the envelope hook. load-bearing. `useResolvedDmRelays` is exported for the envelope hook.
- **Push service built — the HOLD is lifted in principle (2026-10-01).**
Proven end-to-end in a local harness (marked wrap → relay → **keyless**
watcher → Web Push → OS toast with the tab closed → tap opens the app),
then built as **`push-service/`** — our own Bun service (NOT Linky's; no
Effect/linkstr dep) with `web-push`. ⚠️ **Never use Linky's
`["linky","push"]` marker** — ours is `["skatespots","push"]` on the
**recipient copy only** (plaintext on the wrap; unpushed wraps are ignored).
Endpoints: `GET /health` · `GET /vapid-public-key` · `POST /auth/challenge`
→ `POST /subscribe`/`/unsubscribe` (NIP-98 kind-27235 proof with a
**one-time** `challenge` tag). State = JSON file; VAPID keys from env.
Wired like the other standalone servers: `typecheck:push` +
`check:push` (both in `npm test`), `deploy:push`
(`scripts/deploy-push.sh`, generates VAPID keys **on the host**), Caddyfile
+ systemd unit in `push-service/`. ⚠️ **Relay overlap is the trap:** the
marked wrap must land on a relay the service watches — designate the
community relay as the **notify relay** and publish push-marked recipient
copies there. ⚠️ Privacy: the server sees **metadata only** (pubkey ↔ push
endpoint, timing); it **never decrypts**; disclosed on `/privacy`. **STILL
TODO (client half of Phase 1):** the app service worker, the
subscribe/unsubscribe hook + Settings toggle, and adding the marker to
`useSendDirectMessage`'s recipient wraps. Android FCM is Phase 2.
(Approach + tradeoffs came from the Numo/Linky/BTCPay review this session.)
- **Cordn coordinator support (client-side, 2026-09-29):** Cordn is an **MLS - **Cordn coordinator support (client-side, 2026-09-29):** Cordn is an **MLS
delivery coordinator** exposed as a **ContextVM server** over Nostr relays delivery coordinator** exposed as a **ContextVM server** over Nostr relays
(E2EE; it sees ciphertext + *metadata* — who/when/which group). SkateSpots is (E2EE; it sees ciphertext + *metadata* — who/when/which group). SkateSpots is

View file

@ -24,6 +24,8 @@ export default tseslint.config(
"cvm-server", "cvm-server",
// Standalone Bun AI proxy — same deal: own tsconfig (`typecheck:ai`). // Standalone Bun AI proxy — same deal: own tsconfig (`typecheck:ai`).
"ai-proxy", "ai-proxy",
// Standalone Bun push service — own tsconfig (`typecheck:push`).
"push-service",
// Static utility HTML (icon generators, sticker sheets, QR pages) that // Static utility HTML (icon generators, sticker sheets, QR pages) that
// are intentionally minimal and don't need app-level meta/OG tags. // are intentionally minimal and don't need app-level meta/OG tags.
"public/**/*.html", "public/**/*.html",

121
package-lock.json generated
View file

@ -123,6 +123,7 @@
"@types/react": "^18.3.1", "@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@types/three": "^0.185.1", "@types/three": "^0.185.1",
"@types/web-push": "^3.6.4",
"@vitejs/plugin-react-swc": "^3.5.0", "@vitejs/plugin-react-swc": "^3.5.0",
"@webbtc/webln-types": "^3.0.0", "@webbtc/webln-types": "^3.0.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
@ -138,7 +139,8 @@
"typescript": "^5.5.3", "typescript": "^5.5.3",
"typescript-eslint": "^8.0.1", "typescript-eslint": "^8.0.1",
"vite": "^6.3.5", "vite": "^6.3.5",
"vitest": "^3.1.4" "vitest": "^3.1.4",
"web-push": "^3.6.7"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {
@ -4774,6 +4776,16 @@
"meshoptimizer": "~1.1.1" "meshoptimizer": "~1.1.1"
} }
}, },
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/webxr": { "node_modules/@types/webxr": {
"version": "0.5.24", "version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
@ -5746,6 +5758,19 @@
"dequal": "^2.0.3" "dequal": "^2.0.3"
} }
}, },
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"dev": true,
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/assertion-error": { "node_modules/assertion-error": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@ -5963,6 +5988,13 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/bn.js": {
"version": "4.12.5",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
"dev": true,
"license": "MIT"
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
@ -6161,6 +6193,13 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/bundle-name": { "node_modules/bundle-name": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz",
@ -7061,6 +7100,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ecpair": { "node_modules/ecpair": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/ecpair/-/ecpair-3.0.0.tgz", "resolved": "https://registry.npmjs.org/ecpair/-/ecpair-3.0.0.tgz",
@ -8166,6 +8215,16 @@
"integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==", "integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@ -8692,6 +8751,29 @@
"graceful-fs": "^4.1.6" "graceful-fs": "^4.1.6"
} }
}, },
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"dev": true,
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -8972,6 +9054,13 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"dev": true,
"license": "ISC"
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -8985,6 +9074,16 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": { "node_modules/minipass": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
@ -12947,6 +13046,26 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/webidl-conversions": { "node_modules/webidl-conversions": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",

View file

@ -9,11 +9,14 @@
"build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'", "build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'",
"deploy:cvm": "bash scripts/deploy-cvm.sh", "deploy:cvm": "bash scripts/deploy-cvm.sh",
"deploy:ai": "bash scripts/deploy-ai-proxy.sh", "deploy:ai": "bash scripts/deploy-ai-proxy.sh",
"deploy:push": "bash scripts/deploy-push.sh",
"typecheck:cvm": "tsc -p cvm-server/tsconfig.json", "typecheck:cvm": "tsc -p cvm-server/tsconfig.json",
"typecheck:ai": "tsc -p ai-proxy/tsconfig.json", "typecheck:ai": "tsc -p ai-proxy/tsconfig.json",
"typecheck:push": "tsc -p push-service/tsconfig.json",
"check:cvm": "bun build cvm-server/server.ts --target=bun --outfile=/tmp/cvm-check.js", "check:cvm": "bun build cvm-server/server.ts --target=bun --outfile=/tmp/cvm-check.js",
"check:ai": "bun build ai-proxy/server.ts --target=bun --outfile=/tmp/ai-check.js", "check:ai": "bun build ai-proxy/server.ts --target=bun --outfile=/tmp/ai-check.js",
"test": "npm i --silent && tsc --noEmit && tsc -p cvm-server/tsconfig.json && tsc -p ai-proxy/tsconfig.json && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'", "check:push": "bun build push-service/server.ts --target=bun --outfile=/tmp/push-check.js",
"test": "npm i --silent && tsc --noEmit && tsc -p cvm-server/tsconfig.json && tsc -p ai-proxy/tsconfig.json && tsc -p push-service/tsconfig.json && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
"deploy": "npm i --silent && npm run build && npx -y nostr-deploy-cli deploy --skip-setup", "deploy": "npm i --silent && npm run build && npx -y nostr-deploy-cli deploy --skip-setup",
"android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs", "android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs",
"android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs", "android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs",
@ -138,6 +141,7 @@
"@types/react": "^18.3.1", "@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@types/three": "^0.185.1", "@types/three": "^0.185.1",
"@types/web-push": "^3.6.4",
"@vitejs/plugin-react-swc": "^3.5.0", "@vitejs/plugin-react-swc": "^3.5.0",
"@webbtc/webln-types": "^3.0.0", "@webbtc/webln-types": "^3.0.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
@ -153,6 +157,7 @@
"typescript": "^5.5.3", "typescript": "^5.5.3",
"typescript-eslint": "^8.0.1", "typescript-eslint": "^8.0.1",
"vite": "^6.3.5", "vite": "^6.3.5",
"vitest": "^3.1.4" "vitest": "^3.1.4",
"web-push": "^3.6.7"
} }
} }

26
push-service/.env.example Normal file
View file

@ -0,0 +1,26 @@
# SkateSpots push service — copy to .env and fill in.
# Port to listen on (Caddy proxies to this).
PORT=8788
# VAPID keys for Web Push. Generate once with:
# node -e "console.log(require('web-push').generateVAPIDKeys())"
# Keep the private key secret. Changing these INVALIDATES all subscriptions.
PUSH_VAPID_SUBJECT=mailto:owner@skate-spots.org
PUSH_VAPID_PUBLIC_KEY=
PUSH_VAPID_PRIVATE_KEY=
# Relays to watch for push-marked kind-1059 wraps (comma-separated).
PUSH_RELAYS=wss://goskatespots.nostr1.com
# The plaintext push marker tag name on recipient wraps: ["<marker>", "push"].
PUSH_MARKER=skatespots
# Where subscriptions + challenges are stored.
PUSH_STORAGE_PATH=/home/ubuntu/skatespots-push/push-state.json
# How far back to look on each (re)subscribe, in seconds. Default 3 days.
PUSH_LOOKBACK_SECONDS=259200
# Origins allowed to call /subscribe (comma-separated).
PUSH_ALLOWED_ORIGINS=https://skate-spots.org,http://localhost:8080

11
push-service/Caddyfile Normal file
View file

@ -0,0 +1,11 @@
# Caddyfile for the push service host.
#
# Caddy obtains a Let's Encrypt certificate automatically (HTTP-01), so ports 80
# and 443 must be reachable. The service itself binds localhost only.
#
# sudo cp Caddyfile /etc/caddy/Caddyfile && sudo systemctl reload caddy
push.skate-spots.org {
encode gzip
reverse_proxy 127.0.0.1:8788
}

81
push-service/README.md Normal file
View file

@ -0,0 +1,81 @@
# 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.

16
push-service/package.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "skatespots-push",
"private": true,
"type": "module",
"scripts": {
"start": "bun run server.ts",
"dev": "bun --watch run server.ts"
},
"dependencies": {
"nostr-tools": "^2.13.0",
"web-push": "^3.6.7"
},
"devDependencies": {
"@types/web-push": "^3.6.4"
}
}

259
push-service/server.ts Normal file
View file

@ -0,0 +1,259 @@
/**
* SkateSpots push service
* ─────────────────────────────────────────────────────────────────────────────
* A keyless Bun service that delivers **closed-app notifications** for NIP-17
* gift wraps. It watches relays for kind-1059 wraps carrying the plaintext push
* marker `["<PUSH_MARKER>", "push"]`, extracts ONLY the recipient pubkey and the
* wrap id, and sends a Web Push. It **never decrypts** anything — the client
* fetches + decrypts the named wrap itself.
*
* Protocol (ours — independent of, though inspired by, Linky):
* 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 }
*
* `proof` is a NIP-98 kind-27235 event signed by the subscribing pubkey,
* carrying ["challenge", <value>] from /auth/challenge — it proves ownership
* of that pubkey. A challenge is one-time (consumed on use).
*
* State is a small JSON file (subscriptions + challenges). Env + deploy: README.md.
*/
import { readFileSync, writeFileSync } from 'node:fs';
import webpush from 'web-push';
import { verifyEvent, type NostrEvent } from 'nostr-tools';
// Bun provides this at runtime; declare it so the standalone tsc check passes
// without pulling all of @types/bun into the app's toolchain.
declare const Bun: {
serve(options: { port: number; fetch: (req: Request) => Response | Promise<Response> }): unknown;
};
// ─── Config ──────────────────────────────────────────────────────────────────
const env = process.env;
const PORT = Number(env.PORT ?? 8788);
const MARKER = (env.PUSH_MARKER ?? 'skatespots').trim();
const STORAGE_PATH = env.PUSH_STORAGE_PATH ?? './push-state.json';
const RELAYS = (env.PUSH_RELAYS ?? 'wss://goskatespots.nostr1.com')
.split(',').map((s) => s.trim()).filter(Boolean);
const LOOKBACK_SECONDS = Number(env.PUSH_LOOKBACK_SECONDS ?? 3 * 24 * 60 * 60);
const CHALLENGE_TTL_SECONDS = Number(env.PUSH_CHALLENGE_TTL_SECONDS ?? 5 * 60);
const ORIGINS = (env.PUSH_ALLOWED_ORIGINS ?? 'https://skate-spots.org,http://localhost:8080')
.split(',').map((s) => s.trim()).filter(Boolean);
let vapidPublic = env.PUSH_VAPID_PUBLIC_KEY ?? '';
let vapidPrivate = env.PUSH_VAPID_PRIVATE_KEY ?? '';
const vapidSubject = env.PUSH_VAPID_SUBJECT ?? 'mailto:owner@skate-spots.org';
if (!vapidPublic || !vapidPrivate) {
const generated = webpush.generateVAPIDKeys();
vapidPublic = generated.publicKey;
vapidPrivate = generated.privateKey;
console.warn('[push] ⚠️ PUSH_VAPID_* not set — generated EPHEMERAL keys (subscriptions break on restart).');
}
webpush.setVapidDetails(vapidSubject, vapidPublic, vapidPrivate);
// ─── State (JSON file) ───────────────────────────────────────────────────────
interface StoredSubscription { endpoint: string; pubkey: string; p256dh: string; auth: string }
interface State {
subscriptions: Record<string, StoredSubscription>; // endpoint → sub
challenges: Record<string, { pubkey: string; expiresAt: number }>;
}
function load(): State {
try {
const parsed = JSON.parse(readFileSync(STORAGE_PATH, 'utf8')) as Partial<State>;
return { subscriptions: parsed.subscriptions ?? {}, challenges: parsed.challenges ?? {} };
} catch {
return { subscriptions: {}, challenges: {} };
}
}
const state = load();
function save(): void {
try { writeFileSync(STORAGE_PATH, JSON.stringify(state)); }
catch (err) { console.warn('[push] could not persist state:', (err as Error).message); }
}
function sweepChallenges(): void {
const now = Math.floor(Date.now() / 1000);
for (const [id, c] of Object.entries(state.challenges)) if (c.expiresAt < now) delete state.challenges[id];
}
// ─── NIP-98 proof ────────────────────────────────────────────────────────────
function tagValue(ev: NostrEvent, name: string): string | undefined {
return ev.tags.find(([n]) => n === name)?.[1];
}
/** Verify a kind-27235 ownership proof for `expectedPubkey`, consuming its challenge. */
function verifyProof(proof: unknown, expectedPubkey: string): { ok: boolean; error?: string } {
if (!proof || typeof proof !== 'object') return { ok: false, error: 'missing proof' };
const ev = proof as NostrEvent;
if (ev.kind !== 27235) return { ok: false, error: 'proof must be kind 27235' };
if (ev.pubkey !== expectedPubkey) return { ok: false, error: 'proof pubkey mismatch' };
const challenge = tagValue(ev, 'challenge');
if (!challenge) return { ok: false, error: 'proof missing challenge' };
if (!verifyEvent(ev)) return { ok: false, error: 'bad proof signature' };
const row = state.challenges[challenge];
delete state.challenges[challenge]; // one-time, even on failure
save();
if (!row) return { ok: false, error: 'unknown challenge' };
if (row.pubkey !== expectedPubkey) return { ok: false, error: 'challenge/pubkey mismatch' };
if (row.expiresAt < Math.floor(Date.now() / 1000)) return { ok: false, error: 'challenge expired' };
return { ok: true };
}
// ─── Web Push ────────────────────────────────────────────────────────────────
function subsFor(pubkey: string): StoredSubscription[] {
return Object.values(state.subscriptions).filter((s) => s.pubkey === pubkey);
}
async function pushTo(pubkey: string, wrapId: string): Promise<void> {
for (const s of subsFor(pubkey)) {
try {
await webpush.sendNotification(
{ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } },
JSON.stringify({ title: 'SkateSpots', body: 'New message', wrapId, url: '/#/messages' }),
);
} catch (err) {
const status = (err as { statusCode?: number }).statusCode;
if (status === 404 || status === 410) {
console.log(`[push] dropping dead subscription for ${pubkey.slice(0, 12)}…`);
delete state.subscriptions[s.endpoint];
save();
} else {
console.warn(`[push] send failed (${status ?? 'error'}) for ${pubkey.slice(0, 12)}…`);
}
}
}
}
// ─── Relay watcher (no keys, no decryption) ──────────────────────────────────
const relayStatus: Record<string, string> = {};
function watchRelay(url: string): void {
relayStatus[url] = 'connecting';
const ws = new WebSocket(url);
const since = Math.floor(Date.now() / 1000) - LOOKBACK_SECONDS;
ws.addEventListener('open', () => {
relayStatus[url] = 'live';
ws.send(JSON.stringify(['REQ', 'skatespots-push', { kinds: [1059], since }]));
console.log(`[push] watching ${url}`);
});
ws.addEventListener('message', (msg: MessageEvent) => {
let parsed: unknown;
try { parsed = JSON.parse(String(msg.data)); } catch { return; }
if (!Array.isArray(parsed) || parsed[0] !== 'EVENT') return;
const ev = parsed[2] as NostrEvent;
if (ev?.kind !== 1059 || !verifyEvent(ev)) return;
const recipients = [...new Set(ev.tags.filter(([n]) => n === 'p').map(([, v]) => v).filter(Boolean))];
if (recipients.length !== 1) return; // fan-out wraps aren't pushable
if (!ev.tags.some(([n, v]) => n === MARKER && v === 'push')) return;
console.log(`[push] 🔔 marked wrap ${ev.id.slice(0, 12)}… → recipient ${recipients[0].slice(0, 12)}…`);
void pushTo(recipients[0], ev.id);
});
const retry = () => { relayStatus[url] = 'down'; setTimeout(() => watchRelay(url), 5000); };
ws.addEventListener('close', retry);
ws.addEventListener('error', () => { try { ws.close(); } catch { /* noop */ } });
}
for (const url of RELAYS) watchRelay(url);
// ─── HTTP ────────────────────────────────────────────────────────────────────
function cors(origin: string | null): Record<string, string> {
if (origin && ORIGINS.includes(origin)) return { 'access-control-allow-origin': origin, vary: 'Origin' };
return {};
}
function json(body: unknown, status = 200, extra: Record<string, string> = {}): Response {
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...extra } });
}
Bun.serve({
port: PORT,
async fetch(req: Request) {
const url = new URL(req.url);
const headers = cors(req.headers.get('origin'));
if (req.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
...headers,
'access-control-allow-methods': 'GET,POST,OPTIONS',
'access-control-allow-headers': 'content-type',
'access-control-max-age': '86400',
},
});
}
if (url.pathname === '/health') {
return json({
ok: true,
subscriptions: Object.keys(state.subscriptions).length,
relays: relayStatus,
}, 200, headers);
}
if (url.pathname === '/vapid-public-key') return json({ key: vapidPublic }, 200, headers);
if (req.method === 'POST' && url.pathname === '/auth/challenge') {
const body = (await req.json().catch(() => null)) as { pubkey?: string } | null;
if (!body?.pubkey || !/^[0-9a-f]{64}$/i.test(body.pubkey)) {
return json({ error: 'valid hex pubkey required' }, 400, headers);
}
sweepChallenges();
const id = crypto.randomUUID();
const expiresAt = Math.floor(Date.now() / 1000) + CHALLENGE_TTL_SECONDS;
state.challenges[id] = { pubkey: body.pubkey.toLowerCase(), expiresAt };
save();
return json({ challenge: id, expiresAt }, 200, headers);
}
if (req.method === 'POST' && url.pathname === '/subscribe') {
const body = (await req.json().catch(() => null)) as {
subscription?: { endpoint?: string; keys?: { p256dh?: string; auth?: string } };
proof?: NostrEvent;
} | null;
const sub = body?.subscription;
if (!sub?.endpoint || !sub.keys?.p256dh || !sub.keys.auth) {
return json({ error: 'subscription { endpoint, keys{ p256dh, auth } } required' }, 400, headers);
}
if (!body?.proof) return json({ error: 'proof required' }, 400, headers);
const check = verifyProof(body.proof, body.proof.pubkey);
if (!check.ok) return json({ error: check.error }, 401, headers);
state.subscriptions[sub.endpoint] = {
endpoint: sub.endpoint,
pubkey: body.proof.pubkey.toLowerCase(),
p256dh: sub.keys.p256dh,
auth: sub.keys.auth,
};
save();
console.log(`[push] ➕ subscribed ${body.proof.pubkey.slice(0, 12)}…`);
return json({ ok: true }, 200, headers);
}
if (req.method === 'POST' && url.pathname === '/unsubscribe') {
const body = (await req.json().catch(() => null)) as { endpoint?: string; proof?: NostrEvent } | null;
if (!body?.endpoint || !body?.proof) return json({ error: 'endpoint and proof required' }, 400, headers);
const check = verifyProof(body.proof, body.proof.pubkey);
if (!check.ok) return json({ error: check.error }, 401, headers);
delete state.subscriptions[body.endpoint];
save();
return json({ ok: true }, 200, headers);
}
return json({ error: 'not found' }, 404, headers);
},
});
console.log(`[push] listening on :${PORT} — marker "${MARKER}", relays: ${RELAYS.join(', ')}`);

View file

@ -0,0 +1,28 @@
# ─────────────────────────────────────────────────────────────────────────────
# systemd unit for the SkateSpots push service (Bun, on the CVM host).
# Installed by scripts/deploy-push.sh — do not hand-edit on the host.
# ─────────────────────────────────────────────────────────────────────────────
[Unit]
Description=SkateSpots push service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/skatespots-push
EnvironmentFile=/home/ubuntu/skatespots-push/.env
ExecStart=/home/ubuntu/.bun/bin/bun run server.ts
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=skatespots-push
# Basic hardening — mirrors the AI proxy unit (bun stays happy; binds localhost).
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,15 @@
{
// Type-checks the standalone Bun push service. Separate from the app's root
// tsconfig (which only includes `src`). Run with `npm run typecheck:push`.
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext", "DOM"],
"types": ["node"],
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["server.ts"]
}

70
scripts/deploy-push.sh Normal file
View file

@ -0,0 +1,70 @@
#!/usr/bin/env bash
#
# Deploy the SkateSpots push service to the CVM host (same box as the CVM server
# and the AI proxy). Same class of service: a Bun process under systemd,
# reachable only via Caddy (TLS) on the public side.
#
# Config/host come from ~/.config/skatespots/cvm.env (never committed).
#
# npm run deploy:push # or: bash scripts/deploy-push.sh
# bash scripts/deploy-push.sh --logs
#
# On first deploy it generates VAPID keys ON THE HOST and writes .env, so the
# private key never leaves the server. Changing those keys invalidates all
# existing subscriptions.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONF="${HOME}/.config/skatespots/cvm.env"
if [[ -f "$CONF" ]]; then
# shellcheck disable=SC1090
source "$CONF"
fi
: "${CVM_HOST:?Set CVM_HOST (e.g. in ~/.config/skatespots/cvm.env) — the host is never committed}"
CVM_SSH_KEY="${CVM_SSH_KEY:-$HOME/.ssh/skatespots-new}"
PUSH_DIR="${PUSH_DIR:-/home/ubuntu/skatespots-push}"
PUSH_SERVICE="${PUSH_SERVICE:-skatespots-push}"
SSH=(ssh -i "$CVM_SSH_KEY" -o BatchMode=yes -o ConnectTimeout=12 "$CVM_HOST")
if [[ "${1:-}" == "--logs" ]]; then
"${SSH[@]}" "journalctl -u $PUSH_SERVICE -n 60 --no-pager"
exit 0
fi
echo "→ Ensuring $PUSH_DIR exists"
"${SSH[@]}" "mkdir -p $PUSH_DIR"
echo "→ Uploading push-service/{server.ts,package.json,tsconfig.json,skatespots-push.service}"
scp -i "$CVM_SSH_KEY" -o BatchMode=yes \
"$REPO_ROOT/push-service/server.ts" \
"$REPO_ROOT/push-service/package.json" \
"$REPO_ROOT/push-service/tsconfig.json" \
"$REPO_ROOT/push-service/skatespots-push.service" \
"$CVM_HOST:$PUSH_DIR/"
echo "→ Creating .env with fresh VAPID keys if missing (private key stays on the host)"
"${SSH[@]}" "cd $PUSH_DIR && if [ ! -f .env ]; then \
~/.bun/bin/bun -e \"import wp from 'web-push'; const k=wp.generateVAPIDKeys(); \
console.log(['PUSH_VAPID_SUBJECT=mailto:owner@skate-spots.org','PUSH_VAPID_PUBLIC_KEY='+k.publicKey,'PUSH_VAPID_PRIVATE_KEY='+k.privateKey,'PUSH_RELAYS=wss://goskatespots.nostr1.com','PUSH_MARKER=skatespots','PUSH_STORAGE_PATH=$PUSH_DIR/push-state.json','PUSH_ALLOWED_ORIGINS=https://skate-spots.org,http://localhost:8080','PORT=8788'].join('\\n'))\" > .env && chmod 600 .env && echo 'created .env'; \
else echo '.env already exists (leaving keys untouched)'; fi"
echo "→ bun install"
"${SSH[@]}" "cd $PUSH_DIR && ~/.bun/bin/bun install --silent 2>&1 | tail -3 || true"
echo "→ Installing the unit and (re)starting $PUSH_SERVICE"
"${SSH[@]}" "sudo cp $PUSH_DIR/skatespots-push.service /etc/systemd/system/ \
&& sudo systemctl daemon-reload \
&& sudo systemctl enable --now $PUSH_SERVICE >/dev/null 2>&1 || true; \
sudo systemctl restart $PUSH_SERVICE \
&& sleep 2 && echo \"active: \$(systemctl is-active $PUSH_SERVICE)\""
echo "→ Health check"
"${SSH[@]}" "curl -s http://127.0.0.1:8788/health || true; echo"
echo "→ Recent log:"
"${SSH[@]}" "journalctl -u $PUSH_SERVICE -n 15 --no-pager | tail -8"
echo "✅ Push service deployed. Public TLS via Caddy — see push-service/README.md."