diff --git a/apps/pwa/.env.example b/apps/pwa/.env.example index 9e32662f..f5916a62 100644 --- a/apps/pwa/.env.example +++ b/apps/pwa/.env.example @@ -30,7 +30,9 @@ COINPAY_OAUTH_CLIENT_ID= RESEND_API_KEY= RESEND_FROM=moshcode -# web push (VAPID) — generate with: node -e "console.log(require('web-push').generateVAPIDKeys())" +# web push (VAPID) — generate with (from apps/pwa): +# node -e "import('@profullstack/notifications/server').then(m => console.log(m.generateVapidKeys()))" +# VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY are accepted as well. VAPID_PUBLIC= VAPID_PRIVATE= VAPID_SUBJECT=mailto:you@example.com diff --git a/apps/pwa/deploy/.env.example b/apps/pwa/deploy/.env.example index f4e0e0a5..698fe70b 100644 --- a/apps/pwa/deploy/.env.example +++ b/apps/pwa/deploy/.env.example @@ -62,7 +62,7 @@ RESEND_API_KEY= RESEND_FROM=moshcode # web push (VAPID) — generate with: -# node -e "console.log(require('web-push').generateVAPIDKeys())" +# node -e "import('@profullstack/notifications/server').then(m => console.log(m.generateVapidKeys()))" # Use a DIFFERENT keypair from prod. A subscription is bound to the key that # created it, so sharing one lets this box push to prod's subscribers. VAPID_PUBLIC= diff --git a/apps/pwa/package-lock.json b/apps/pwa/package-lock.json index 94aa1f19..032012d6 100644 --- a/apps/pwa/package-lock.json +++ b/apps/pwa/package-lock.json @@ -10,14 +10,14 @@ "dependencies": { "@libsql/client": "^0.14.0", "@peculiar/x509": "^1.14.3", + "@profullstack/notifications": "^0.1.0", "@profullstack/synconfig": "^0.1.1", "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.1.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "cookie-parser": "^1.4.7", - "express": "^4.21.2", - "web-push": "^3.6.7" + "express": "^4.21.2" }, "devDependencies": { "@moshcoder/moshpit-name": "^0.4.0" @@ -369,6 +369,15 @@ "node": ">=20.0.0" } }, + "node_modules/@profullstack/notifications": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@profullstack/notifications/-/notifications-0.1.0.tgz", + "integrity": "sha512-17jiBCRiZVDcIyWnWKYr5E2eVog/1iHgXaruGbmlw7j7Ob4zd6oOqGjh8BP8wm13iFwikczYObQwSrGadBdnGw==", + "license": "MIT", + "engines": { + "node": ">=20.11" + } + }, "node_modules/@profullstack/synconfig": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@profullstack/synconfig/-/synconfig-0.1.3.tgz", @@ -449,33 +458,12 @@ "node": ">= 0.6" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, - "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==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.0.0", - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0", - "safer-buffer": "^2.1.0" - } - }, "node_modules/asn1js": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", @@ -490,12 +478,6 @@ "node": ">=12.0.0" } }, - "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==", - "license": "MIT" - }, "node_modules/body-parser": { "version": "1.20.6", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", @@ -520,12 +502,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "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==", - "license": "BSD-3-Clause" - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -673,15 +649,6 @@ "node": ">= 0.4" } }, - "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==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -941,15 +908,6 @@ "node": ">= 0.4" } }, - "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==", - "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", @@ -970,42 +928,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/https-proxy-agent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -1039,27 +961,6 @@ "integrity": "sha512-5xVjhUZlHHeuO2W7w2rDFj/Kl1xLX+HjZxdOQwCsUOifl6UaoH1o1wsbsTMz+r0aeC7gCijvru02j6TfKZWzKg==", "license": "BSD-3-Clause" }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "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==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, "node_modules/libsql": { "version": "0.4.7", "resolved": "https://registry.npmjs.org/libsql/-/libsql-0.4.7.tgz", @@ -1158,21 +1059,6 @@ "node": ">= 0.6" } }, - "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==", - "license": "ISC" - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -1585,25 +1471,6 @@ "node": ">= 0.8" } }, - "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==", - "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/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", diff --git a/apps/pwa/package.json b/apps/pwa/package.json index f7eedad8..64441ad8 100644 --- a/apps/pwa/package.json +++ b/apps/pwa/package.json @@ -16,14 +16,14 @@ "dependencies": { "@libsql/client": "^0.14.0", "@peculiar/x509": "^1.14.3", + "@profullstack/notifications": "^0.1.0", "@profullstack/synconfig": "^0.1.1", "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.1.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "cookie-parser": "^1.4.7", - "express": "^4.21.2", - "web-push": "^3.6.7" + "express": "^4.21.2" }, "devDependencies": { "@moshcoder/moshpit-name": "^0.4.0" diff --git a/apps/pwa/pnpm-lock.yaml b/apps/pwa/pnpm-lock.yaml index 7b53ffdb..ad5cf2e3 100644 --- a/apps/pwa/pnpm-lock.yaml +++ b/apps/pwa/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@peculiar/x509': specifier: ^1.14.3 version: 1.14.3 + '@profullstack/notifications': + specifier: ^0.1.0 + version: 0.1.0 '@profullstack/synconfig': specifier: ^0.1.1 version: 0.1.1 @@ -35,9 +38,6 @@ importers: express: specifier: ^4.21.2 version: 4.22.2 - web-push: - specifier: ^3.6.7 - version: 3.6.7 devDependencies: '@moshcoder/moshpit-name': specifier: ^0.4.0 @@ -161,6 +161,10 @@ packages: resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} engines: {node: '>=20.0.0'} + '@profullstack/notifications@0.1.0': + resolution: {integrity: sha512-17jiBCRiZVDcIyWnWKYr5E2eVog/1iHgXaruGbmlw7j7Ob4zd6oOqGjh8BP8wm13iFwikczYObQwSrGadBdnGw==} + engines: {node: '>=20.11'} + '@profullstack/synconfig@0.1.1': resolution: {integrity: sha512-gX/tZ0j0QShtAf2/Mpr40Fev0h7HVoh5jl3tVbFVrSfuQHalthiwVgZ8lQKzhv+0kpxvVex4qkT8xMFHSopklA==} engines: {node: '>=22'} @@ -188,30 +192,17 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} - agent-base@7.1.4: - resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} - engines: {node: '>= 14'} - array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} - asn1.js@5.4.1: - resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} - asn1js@3.0.10: resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} engines: {node: '>=12.0.0'} - bn.js@4.12.5: - resolution: {integrity: sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==} - body-parser@1.20.8: resolution: {integrity: sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - buffer-equal-constant-time@1.0.1: - resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} - bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -258,15 +249,6 @@ packages: supports-color: optional: true - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -283,9 +265,6 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - ecdsa-sig-formatter@1.0.11: - resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -363,14 +342,6 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} - http_ece@1.2.0: - resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==} - engines: {node: '>=16'} - - https-proxy-agent@7.0.6: - resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} - engines: {node: '>= 14'} - iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -385,12 +356,6 @@ packages: js-base64@3.9.3: resolution: {integrity: sha512-uwYQp+VJ38FVvtim6qNbit6e9uT6dwWQ4Y1+H9TxhW5hcHjpHwoxlR0nMpqUmIFOmu4VqMxwdJA88gIVuZJQ/g==} - jwa@2.0.1: - resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} - - jws@4.0.1: - resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} - libsql@0.4.7: resolution: {integrity: sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw==} cpu: [x64, arm64, wasm32] @@ -424,12 +389,6 @@ packages: engines: {node: '>=4'} hasBin: true - minimalistic-assert@1.0.1: - resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -567,11 +526,6 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - web-push@3.6.7: - resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} - engines: {node: '>= 16'} - hasBin: true - web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -754,6 +708,8 @@ snapshots: tslib: 2.8.1 tsyringe: 4.10.0 + '@profullstack/notifications@0.1.0': {} + '@profullstack/synconfig@0.1.1': {} '@simplewebauthn/browser@13.3.0': {} @@ -786,25 +742,14 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 - agent-base@7.1.4: {} - array-flatten@1.1.1: {} - asn1.js@5.4.1: - dependencies: - bn.js: 4.12.5 - inherits: 2.0.4 - minimalistic-assert: 1.0.1 - safer-buffer: 2.1.2 - asn1js@3.0.10: dependencies: pvtsutils: 1.3.6 pvutils: 1.2.0 tslib: 2.8.1 - bn.js@4.12.5: {} - body-parser@1.20.8: dependencies: bytes: 3.1.2 @@ -822,8 +767,6 @@ snapshots: transitivePeerDependencies: - supports-color - buffer-equal-constant-time@1.0.1: {} - bytes@3.1.2: {} call-bind-apply-helpers@1.0.2: @@ -859,10 +802,6 @@ snapshots: dependencies: ms: 2.0.0 - debug@4.4.3: - dependencies: - ms: 2.1.3 - depd@2.0.0: {} destroy@1.2.0: {} @@ -875,10 +814,6 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - ecdsa-sig-formatter@1.0.11: - dependencies: - safe-buffer: 5.2.1 - ee-first@1.1.1: {} encodeurl@2.0.0: {} @@ -992,15 +927,6 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - http_ece@1.2.0: {} - - https-proxy-agent@7.0.6: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -1011,17 +937,6 @@ snapshots: js-base64@3.9.3: {} - jwa@2.0.1: - dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 - - jws@4.0.1: - dependencies: - jwa: 2.0.1 - safe-buffer: 5.2.1 - libsql@0.4.7: dependencies: '@neon-rs/load': 0.0.4 @@ -1051,10 +966,6 @@ snapshots: mime@1.6.0: {} - minimalistic-assert@1.0.1: {} - - minimist@1.2.8: {} - ms@2.0.0: {} ms@2.1.3: {} @@ -1199,16 +1110,6 @@ snapshots: vary@1.1.2: {} - web-push@3.6.7: - dependencies: - asn1.js: 5.4.1 - http_ece: 1.2.0 - https-proxy-agent: 7.0.6 - jws: 4.0.1 - minimist: 1.2.8 - transitivePeerDependencies: - - supports-color - web-streams-polyfill@3.3.3: {} ws@8.21.3: {} diff --git a/apps/pwa/public/push.js b/apps/pwa/public/push.js index d75700d0..3f922f4f 100644 --- a/apps/pwa/public/push.js +++ b/apps/pwa/public/push.js @@ -1,63 +1,59 @@ /* Web-push toggle for approvals. Reflects whether THIS device is subscribed: - "Enable push on this device" ⇄ "Disable notifications on this device". */ -(function () { - var btn = document.getElementById("push-btn"); - if (!btn) return; - var VAPID = btn.getAttribute("data-vapid"); + "Enable push on this device" ⇄ "Disable notifications on this device". + Runs on @profullstack/notifications/client: the VAPID key is fetched from + /api/push/vapid-public-key when subscribing, and when push cannot work here + pushSupport() says why (not HTTPS, iPhone without Home Screen, blocked…). */ +import { pushSupport, subscribe, unsubscribe, getSubscription } from "/vendor/notifications-client.js"; - function csrf() { - var m = document.cookie.match(/(?:^|; )mc_csrf=([^;]+)/); - return m ? decodeURIComponent(m[1]) : ""; - } - function post(url, body) { - return fetch(url, { - method: "POST", - headers: { "content-type": "application/json", "x-csrf-token": csrf() }, - body: JSON.stringify(body || {}), - }); - } - function urlB64ToUint8(base64) { - var pad = "=".repeat((4 - (base64.length % 4)) % 4); - var b64 = (base64 + pad).replace(/-/g, "+").replace(/_/g, "/"); - var raw = atob(b64), out = new Uint8Array(raw.length); - for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); - return out; - } - function setState(on) { - btn.dataset.on = on ? "1" : "0"; - btn.textContent = on ? "🔕 Disable notifications on this device" : "🔔 Enable push on this device"; - btn.classList.toggle("danger", on); - } +const btn = document.getElementById("push-btn"); +const why = document.getElementById("push-why"); - async function enable() { - var perm = await Notification.requestPermission(); - if (perm !== "granted") { btn.textContent = "permission denied"; return; } - var reg = await navigator.serviceWorker.ready; - var sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64ToUint8(VAPID) }); - var j = sub.toJSON(); - var r = await post("/push/subscribe", { endpoint: j.endpoint, p256dh: j.keys.p256dh, auth: j.keys.auth }); - if (r.ok) setState(true); else btn.textContent = "failed — retry"; - } - async function disable() { - var reg = await navigator.serviceWorker.ready; - var sub = await reg.pushManager.getSubscription(); - if (sub) { await post("/push/unsubscribe", { endpoint: sub.endpoint }); await sub.unsubscribe(); } - setState(false); - } +function csrf() { + const m = document.cookie.match(/(?:^|; )mc_csrf=([^;]+)/); + return m ? decodeURIComponent(m[1]) : ""; +} +function explain(message) { + if (!why) return; + why.textContent = message || ""; + why.hidden = !message; +} +function setState(on) { + btn.dataset.on = on ? "1" : "0"; + btn.textContent = on ? "🔕 Disable notifications on this device" : "🔔 Enable push on this device"; + btn.classList.toggle("danger", on); +} - btn.addEventListener("click", async function () { - if (!("serviceWorker" in navigator) || !("PushManager" in window)) { btn.disabled = true; btn.textContent = "push unsupported"; return; } - if (!VAPID) { btn.textContent = "push not configured"; return; } +async function enable() { + await subscribe({ saveUrl: "/push/subscribe", headers: { "x-csrf-token": csrf() } }); + explain(""); + setState(true); +} +async function disable() { + await unsubscribe({ removeUrl: "/push/unsubscribe", headers: { "x-csrf-token": csrf() } }); + setState(false); +} + +if (btn) { + btn.addEventListener("click", async () => { btn.disabled = true; - try { if (btn.dataset.on === "1") await disable(); else await enable(); } - catch (e) { btn.textContent = "failed — retry"; } - finally { btn.disabled = false; } + try { + if (btn.dataset.on === "1") await disable(); else await enable(); + } catch (e) { + btn.textContent = e && e.reason === "denied" ? "permission denied" : "failed — retry"; + explain(e && e.message); + } finally { + btn.disabled = false; + } }); - // reflect current state on load - (async function () { - if (!("serviceWorker" in navigator) || !("PushManager" in window)) { btn.disabled = true; btn.textContent = "push unsupported here"; return; } - try { var reg = await navigator.serviceWorker.ready; setState(!!(await reg.pushManager.getSubscription())); } + // reflect current state on load, and say why when push cannot work here + (async () => { + const support = pushSupport(); + if (!support.supported) { + explain(support.message); + if (support.reason !== "denied") { btn.disabled = true; btn.textContent = "push unsupported here"; return; } + } + try { setState(!!(await getSubscription())); } catch (e) { /* leave default label */ } })(); -})(); +} diff --git a/apps/pwa/src/config.mjs b/apps/pwa/src/config.mjs index e2ae91c6..28363daa 100644 --- a/apps/pwa/src/config.mjs +++ b/apps/pwa/src/config.mjs @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { vapidKeysFromEnv } from "@profullstack/notifications/server"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -106,9 +107,15 @@ export const config = { apiBase: (process.env.FORWARDEMAIL_API_BASE || "https://api.forwardemail.net").replace(/\/+$/, ""), domain: (process.env.MOSHPIT_GUARD_DOMAIN || "names.moshcode.sh").trim().toLowerCase(), }, + // Web Push (@profullstack/notifications). The public key reaches browsers at + // run time from GET /api/push/vapid-public-key, never rendered into a page. + // VAPID_PUBLIC / VAPID_PRIVATE are this app's historic names; the package's + // VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY work too. null when either is missing. push: { - vapidPublic: process.env.VAPID_PUBLIC || "", - vapidPrivate: process.env.VAPID_PRIVATE || "", + keys: vapidKeysFromEnv({ + VAPID_PUBLIC_KEY: process.env.VAPID_PUBLIC_KEY || process.env.VAPID_PUBLIC, + VAPID_PRIVATE_KEY: process.env.VAPID_PRIVATE_KEY || process.env.VAPID_PRIVATE, + }), subject: process.env.VAPID_SUBJECT || "mailto:anthony@profullstack.com", }, telegram: { botToken: process.env.TELEGRAM_BOT_TOKEN || "" }, diff --git a/apps/pwa/src/lib/deliver.mjs b/apps/pwa/src/lib/deliver.mjs index 550ed538..00e84259 100644 --- a/apps/pwa/src/lib/deliver.mjs +++ b/apps/pwa/src/lib/deliver.mjs @@ -2,17 +2,17 @@ // email → Resend // slack → Slack incoming webhook (channel target = webhook URL) // telegram → Telegram bot sendMessage (channel target = chat id; needs bot token) -// push → Web Push (VAPID) to the user's subscribed devices +// push → Web Push (VAPID, @profullstack/notifications) to the user's subscribed devices // sms → stubbed (wire a provider next) // Returns the channel kinds that actually accepted. -import webpush from "web-push"; +import { sendPushToMany } from "@profullstack/notifications/server"; import { all, run } from "../db.mjs"; import { config } from "../config.mjs"; import { esc } from "./html.mjs"; -if (config.push.vapidPublic && config.push.vapidPrivate) { - webpush.setVapidDetails(config.push.subject, config.push.vapidPublic, config.push.vapidPrivate); -} +// web-push's default TTL (4 weeks), kept so an approval still reaches a phone +// that was off for a while. +const PUSH_TTL = 60 * 60 * 24 * 7 * 4; async function sendEmail(to, a) { if (!config.resend.apiKey) { console.log(`[email:stub] → ${to}: ${a.message}`); return true; } @@ -82,20 +82,22 @@ async function sendTelegram(chatId, a) { return res.ok; } -async function sendPush(user, a) { +async function sendPushToUser(user, a) { const subs = await all(`SELECT * FROM push_subscriptions WHERE user_id = ?`, [user.id]); - if (!subs.length || !config.push.vapidPublic) { console.log(`[push:stub] ${a.message}`); return subs.length ? true : false; } + if (!subs.length || !config.push.keys) { console.log(`[push:stub] ${a.message}`); return subs.length ? true : false; } const payload = JSON.stringify({ title: "moshcode needs you 🤘", body: a.message, url: a.url }); - let any = false; - for (const s of subs) { - try { - await webpush.sendNotification({ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } }, payload); - any = true; - } catch (e) { - if (e.statusCode === 404 || e.statusCode === 410) await run(`DELETE FROM push_subscriptions WHERE id = ?`, [s.id]); - } - } - return any; + const results = await sendPushToMany( + subs.map((s) => ({ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } })), + payload, + { + keys: config.push.keys, + subject: config.push.subject, + ttl: PUSH_TTL, + // 404/410: the browser dropped this subscription — forget it. + onGone: (endpoint) => run(`DELETE FROM push_subscriptions WHERE endpoint = ? AND user_id = ?`, [endpoint, user.id]), + }, + ); + return results.some((r) => r.sent); } // Deliver to enabled channels (optionally limited to `onlyKinds`); returns kinds @@ -110,7 +112,7 @@ export async function fanOut(user, approval, onlyKinds = null) { if (c.kind === "email") ok = await sendEmail(c.target || user.email, approval); else if (c.kind === "slack") ok = await sendSlack(c.target || config.slack.defaultWebhook, approval); else if (c.kind === "telegram") ok = await sendTelegram(c.target, approval); - else if (c.kind === "push") ok = await sendPush(user, approval); + else if (c.kind === "push") ok = await sendPushToUser(user, approval); else if (c.kind === "webhook") ok = await sendWebhook(c.target, approval); else if (c.kind === "sms" && c.target) { console.log(`[sms:stub] → ${c.target}: ${approval.message}`); diff --git a/apps/pwa/src/routes/pages.mjs b/apps/pwa/src/routes/pages.mjs index 7fec1225..73bf4fc4 100644 --- a/apps/pwa/src/routes/pages.mjs +++ b/apps/pwa/src/routes/pages.mjs @@ -9,6 +9,7 @@ import { createApiKey, listApiKeys, revokeApiKey } from "../lib/apikey.mjs"; import { PACKS } from "./credits.mjs"; import { latestSnapshotMeta } from "./settings-sync.mjs"; import { config } from "../config.mjs"; +import { parseSubscription, vapidPublicKeyResponse } from "@profullstack/notifications/server"; export const pagesRouter = Router(); @@ -89,12 +90,13 @@ export async function dashboardHandler(req, res) {
Channelsmanage

where your pings land — email, Slack, Telegram, SMS, push. Configure in settings.

- + +
${footer} - `; + `; res.type("html").send(page({ title: "moshcode ▸ dashboard", body })); } @@ -264,10 +266,22 @@ pagesRouter.post("/settings/apikeys/:id/delete", requireAuth, async (req, res) = res.redirect("/settings"); }); -// web push subscription for this device (session-authed; CSRF via x-csrf-token) +// The VAPID public key, served at run time so the browser never depends on a +// key rendered into the page. 503 + { publicKey: null } when push is not set up. +pagesRouter.get("/api/push/vapid-public-key", async (_req, res) => { + const out = vapidPublicKeyResponse(config.push.keys); + res.status(out.status).set(Object.fromEntries(out.headers)).send(await out.text()); +}); + +// web push subscription for this device (session-authed; CSRF via x-csrf-token). +// Takes a PushSubscription's toJSON() ({ endpoint, keys: { p256dh, auth } }), +// as @profullstack/notifications/client sends it; the older flat { endpoint, +// p256dh, auth } shape is still accepted. pagesRouter.post("/push/subscribe", requireAuth, async (req, res) => { - const { endpoint, p256dh, auth } = req.body || {}; - if (!endpoint || !p256dh || !auth) return res.status(400).json({ error: "bad subscription" }); + const body = req.body || {}; + const sub = parseSubscription(body.keys ? body : { endpoint: body.endpoint, keys: { p256dh: body.p256dh, auth: body.auth } }); + if (!sub) return res.status(400).json({ error: "bad subscription" }); + const { endpoint, keys: { p256dh, auth } } = sub; const existing = await get(`SELECT id FROM push_subscriptions WHERE endpoint = ?`, [endpoint]); if (existing) await run(`UPDATE push_subscriptions SET user_id=?, p256dh=?, auth=? WHERE endpoint=?`, [req.user.id, p256dh, auth, endpoint]); else await run(`INSERT INTO push_subscriptions (id,user_id,endpoint,p256dh,auth,created_at) VALUES (?,?,?,?,?,?)`, @@ -275,7 +289,11 @@ pagesRouter.post("/push/subscribe", requireAuth, async (req, res) => { res.json({ ok: true }); }); -pagesRouter.post("/push/unsubscribe", requireAuth, async (req, res) => { +// POST is the original form; DELETE is what @profullstack/notifications/client's +// unsubscribe({ removeUrl }) sends. +async function forgetSubscription(req, res) { if (req.body?.endpoint) await run(`DELETE FROM push_subscriptions WHERE endpoint = ? AND user_id = ?`, [req.body.endpoint, req.user.id]); res.json({ ok: true }); -}); +} +pagesRouter.post("/push/unsubscribe", requireAuth, forgetSubscription); +pagesRouter.delete("/push/unsubscribe", requireAuth, forgetSubscription); diff --git a/apps/pwa/src/server.mjs b/apps/pwa/src/server.mjs index d7ef306f..9eb03c84 100644 --- a/apps/pwa/src/server.mjs +++ b/apps/pwa/src/server.mjs @@ -58,6 +58,8 @@ const vendor = { "/vendor/xterm.js": "node_modules/@xterm/xterm/lib/xterm.js", "/vendor/xterm.css": "node_modules/@xterm/xterm/css/xterm.css", "/vendor/xterm-addon-fit.js": "node_modules/@xterm/addon-fit/lib/addon-fit.js", + // the Web Push browser client (an ES module) that public/push.js imports + "/vendor/notifications-client.js": "node_modules/@profullstack/notifications/src/client.js", }; for (const [route, file] of Object.entries(vendor)) { app.get(route, (_req, res) => res.sendFile(path.join(config.root, file), { maxAge: "1h" })); diff --git a/apps/pwa/test/push-notifications.test.mjs b/apps/pwa/test/push-notifications.test.mjs new file mode 100644 index 00000000..c033d910 --- /dev/null +++ b/apps/pwa/test/push-notifications.test.mjs @@ -0,0 +1,183 @@ +// Web Push on @profullstack/notifications: the VAPID public key is served at +// run time, subscriptions arrive as PushSubscription.toJSON(), and delivery +// forgets a subscription the push service reports gone (404/410). +// +// Boots the real pages router against a throwaway libsql file database, and +// skips cleanly when the PWA dependencies are not installed. +import assert from "node:assert/strict"; +import { createECDH, randomBytes } from "node:crypto"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +let deps = null; +let generateVapidKeys = null; +try { + deps = { express: require("express"), cookieParser: require("cookie-parser") }; + ({ generateVapidKeys } = await import("@profullstack/notifications/server")); +} catch { + deps = null; // pwa dependencies not installed — tests below skip +} +const skip = !deps && "pwa deps not installed"; + +// config reads the environment once, at import time — set it first. The app's +// historic variable names, so existing deploys keep their keys. +const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-pwa-push-test-")); +process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`; +process.env.SESSION_SECRET = "test-secret"; +const KEYS = generateVapidKeys ? generateVapidKeys() : null; +if (KEYS) { + process.env.VAPID_PUBLIC = KEYS.publicKey; + process.env.VAPID_PRIVATE = KEYS.privateKey; +} + +// A subscription a real browser could have made: a P-256 point and a 16-byte secret. +function browserSubscription(endpoint) { + const ecdh = createECDH("prime256v1"); + ecdh.generateKeys(); + return { + endpoint, + expirationTime: null, + keys: { p256dh: ecdh.getPublicKey().toString("base64url"), auth: randomBytes(16).toString("base64url") }, + }; +} + +async function boot() { + const { migrate } = await import("../src/migrate.mjs"); + await migrate(); + const { run, all } = await import("../src/db.mjs"); + const { sessionMiddleware, csrfGuard } = await import("../src/lib/session.mjs"); + const { pagesRouter } = await import("../src/routes/pages.mjs"); + const { fanOut } = await import("../src/lib/deliver.mjs"); + const { id, token } = await import("../src/lib/crypto.mjs"); + + const app = deps.express(); + app.use(deps.express.json()); + app.use(deps.cookieParser()); + app.use(sessionMiddleware); + app.use(csrfGuard); + app.use(pagesRouter); + const server = await new Promise((resolve) => { + const s = app.listen(0, "127.0.0.1", () => resolve(s)); + }); + const base = `http://127.0.0.1:${server.address().port}`; + + const seedUser = async (userId) => { + await run(`INSERT INTO users (id, email, display_name, created_at) VALUES (?,?,?,?)`, + [userId, `${userId}@b.c`, "demo", Date.now()]); + await run(`INSERT INTO channels (id,user_id,kind,target,enabled,created_at) VALUES (?,?,?,?,?,?)`, + [id(), userId, "push", null, 1, Date.now()]); + const sess = token(); + await run(`INSERT INTO sessions (token,user_id,created_at,expires_at) VALUES (?,?,?,?)`, + [sess, userId, Date.now(), Date.now() + 3600e3]); + return { id: userId, email: `${userId}@b.c`, sess }; + }; + const addSubscription = (userId, sub) => + run(`INSERT INTO push_subscriptions (id,user_id,endpoint,p256dh,auth,created_at) VALUES (?,?,?,?,?,?)`, + [id(), userId, sub.endpoint, sub.keys.p256dh, sub.keys.auth, Date.now()]); + const post = (sess, url, body, method = "POST") => fetch(`${base}${url}`, { + method, + headers: { "content-type": "application/json", cookie: `mc_sess=${sess}; mc_csrf=tok`, "x-csrf-token": "tok" }, + body: JSON.stringify(body), + }); + + return { base, server, run, all, seedUser, addSubscription, post, fanOut }; +} + +test("GET /api/push/vapid-public-key serves the key at run time", { skip }, async () => { + const { base, server } = await boot(); + try { + const res = await fetch(`${base}/api/push/vapid-public-key`); + assert.equal(res.status, 200); + assert.match(res.headers.get("content-type"), /application\/json/); + assert.deepEqual(await res.json(), { publicKey: KEYS.publicKey }); + } finally { server.close(); } +}); + +test("the dashboard no longer renders the key into the page", { skip }, async () => { + const { base, server, seedUser } = await boot(); + try { + const u = await seedUser("u-push-dash"); + const html = await fetch(`${base}/dashboard`, { headers: { cookie: `mc_sess=${u.sess}` } }).then((r) => r.text()); + assert.ok(html.includes('id="push-btn"')); + assert.ok(!html.includes(KEYS.publicKey), "the public key comes from /api/push/vapid-public-key"); + assert.ok(html.includes('