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:
parent
b1d21f89ba
commit
807037ee9b
13 changed files with 658 additions and 3 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
22
AGENTS.md
22
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
|
(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
|
||||||
|
|
|
||||||
|
|
@ -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
121
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
26
push-service/.env.example
Normal 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
11
push-service/Caddyfile
Normal 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
81
push-service/README.md
Normal 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
16
push-service/package.json
Normal 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
259
push-service/server.ts
Normal 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(', ')}`);
|
||||||
28
push-service/skatespots-push.service
Normal file
28
push-service/skatespots-push.service
Normal 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
|
||||||
15
push-service/tsconfig.json
Normal file
15
push-service/tsconfig.json
Normal 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
70
scripts/deploy-push.sh
Normal 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."
|
||||||
Loading…
Reference in a new issue