diff --git a/.gitignore b/.gitignore index 56fbb31..931ac0a 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ stats.json /resources/ /bun.lock cvm-server/bun.lock +push-service/bun.lock # CVM server local dev manifest — the tracked manifest is package.cvm.json # (DEPLOY.md does `cp package.cvm.json package.json` on the server). cvm-server/package.json diff --git a/AGENTS.md b/AGENTS.md index 35c8177..1ecfee6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 closed-app DMs/crew are a documented limitation, revisited only if it becomes 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 delivery coordinator** exposed as a **ContextVM server** over Nostr relays (E2EE; it sees ciphertext + *metadata* — who/when/which group). SkateSpots is diff --git a/eslint.config.js b/eslint.config.js index b14f946..440e04d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -24,6 +24,8 @@ export default tseslint.config( "cvm-server", // Standalone Bun AI proxy — same deal: own tsconfig (`typecheck:ai`). "ai-proxy", + // Standalone Bun push service — own tsconfig (`typecheck:push`). + "push-service", // Static utility HTML (icon generators, sticker sheets, QR pages) that // are intentionally minimal and don't need app-level meta/OG tags. "public/**/*.html", diff --git a/package-lock.json b/package-lock.json index 650093f..d93029d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -123,6 +123,7 @@ "@types/react": "^18.3.1", "@types/react-dom": "^18.3.1", "@types/three": "^0.185.1", + "@types/web-push": "^3.6.4", "@vitejs/plugin-react-swc": "^3.5.0", "@webbtc/webln-types": "^3.0.0", "autoprefixer": "^10.4.20", @@ -138,7 +139,8 @@ "typescript": "^5.5.3", "typescript-eslint": "^8.0.1", "vite": "^6.3.5", - "vitest": "^3.1.4" + "vitest": "^3.1.4", + "web-push": "^3.6.7" } }, "node_modules/@adobe/css-tools": { @@ -4774,6 +4776,16 @@ "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": { "version": "0.5.24", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", @@ -5746,6 +5758,19 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -5963,6 +5988,13 @@ "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": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -6161,6 +6193,13 @@ "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": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", @@ -7061,6 +7100,16 @@ "dev": true, "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/ecpair/-/ecpair-3.0.0.tgz", @@ -8166,6 +8215,16 @@ "integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==", "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -8692,6 +8751,29 @@ "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": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -8972,6 +9054,13 @@ "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": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -8985,6 +9074,16 @@ "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": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -12947,6 +13046,26 @@ "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": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/package.json b/package.json index e62aa1d..210925c 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,14 @@ "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:ai": "bash scripts/deploy-ai-proxy.sh", + "deploy:push": "bash scripts/deploy-push.sh", "typecheck:cvm": "tsc -p cvm-server/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: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", "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", @@ -138,6 +141,7 @@ "@types/react": "^18.3.1", "@types/react-dom": "^18.3.1", "@types/three": "^0.185.1", + "@types/web-push": "^3.6.4", "@vitejs/plugin-react-swc": "^3.5.0", "@webbtc/webln-types": "^3.0.0", "autoprefixer": "^10.4.20", @@ -153,6 +157,7 @@ "typescript": "^5.5.3", "typescript-eslint": "^8.0.1", "vite": "^6.3.5", - "vitest": "^3.1.4" + "vitest": "^3.1.4", + "web-push": "^3.6.7" } } diff --git a/push-service/.env.example b/push-service/.env.example new file mode 100644 index 0000000..af6c279 --- /dev/null +++ b/push-service/.env.example @@ -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: ["", "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 diff --git a/push-service/Caddyfile b/push-service/Caddyfile new file mode 100644 index 0000000..14b53dc --- /dev/null +++ b/push-service/Caddyfile @@ -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 +} diff --git a/push-service/README.md b/push-service/README.md new file mode 100644 index 0000000..4878dc0 --- /dev/null +++ b/push-service/README.md @@ -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", ]` 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. diff --git a/push-service/package.json b/push-service/package.json new file mode 100644 index 0000000..99dd8a6 --- /dev/null +++ b/push-service/package.json @@ -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" + } +} diff --git a/push-service/server.ts b/push-service/server.ts new file mode 100644 index 0000000..dd1f799 --- /dev/null +++ b/push-service/server.ts @@ -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"]`, 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", ] 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 }): 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; // endpoint → sub + challenges: Record; +} + +function load(): State { + try { + const parsed = JSON.parse(readFileSync(STORAGE_PATH, 'utf8')) as Partial; + 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 { + 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 = {}; + +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 { + if (origin && ORIGINS.includes(origin)) return { 'access-control-allow-origin': origin, vary: 'Origin' }; + return {}; +} +function json(body: unknown, status = 200, extra: Record = {}): 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(', ')}`); diff --git a/push-service/skatespots-push.service b/push-service/skatespots-push.service new file mode 100644 index 0000000..2edbbe8 --- /dev/null +++ b/push-service/skatespots-push.service @@ -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 diff --git a/push-service/tsconfig.json b/push-service/tsconfig.json new file mode 100644 index 0000000..9663902 --- /dev/null +++ b/push-service/tsconfig.json @@ -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"] +} diff --git a/scripts/deploy-push.sh b/scripts/deploy-push.sh new file mode 100644 index 0000000..52b5bca --- /dev/null +++ b/scripts/deploy-push.sh @@ -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."