diff --git a/package.json b/package.json index 3fafa7673ce..157c0b860bf 100644 --- a/package.json +++ b/package.json @@ -1642,6 +1642,7 @@ "tslog": "^4.10.2", "typebox": "1.1.31", "undici": "8.1.0", + "web-push": "^3.6.7", "ws": "^8.20.0", "yaml": "^2.8.3", "zod": "^4.3.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d53df1cfc38..a7f6ab0d2b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,6 +135,9 @@ importers: undici: specifier: 8.1.0 version: 8.1.0 + web-push: + specifier: ^3.6.7 + version: 3.6.7 ws: specifier: ^8.20.0 version: 8.20.0 @@ -2242,105 +2245,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -2532,28 +2519,24 @@ packages: engines: {node: '>= 18'} cpu: [arm64] os: [linux] - libc: [glibc] '@lancedb/lancedb-linux-arm64-musl@0.27.2': resolution: {integrity: sha512-bK5Mc50EvwGZaaiym5CoPu8Y4GNSyEEvTQ0dTC2AUIm83qdQu1rGw6kkYtc/rTH/hbvAvPQot4agHDZfMVxfYw==} engines: {node: '>= 18'} cpu: [arm64] os: [linux] - libc: [musl] '@lancedb/lancedb-linux-x64-gnu@0.27.2': resolution: {integrity: sha512-qe+ML0YmPru0o84f33RBHqoNk6zsHBjiXTLKsEBDiiFYKks/XMsrkKy9NQYcTxShBrg/nx/MLzCzd7dihqgNYw==} engines: {node: '>= 18'} cpu: [x64] os: [linux] - libc: [glibc] '@lancedb/lancedb-linux-x64-musl@0.27.2': resolution: {integrity: sha512-ZpX6Oxn06qvzAdm+D/gNb3SRp/A9lgRAPvPg6nnMmSQk5XamC/hbGO07uK1wwop7nlqXUH/thk4is2y2ieWdTw==} engines: {node: '>= 18'} cpu: [x64] os: [linux] - libc: [musl] '@lancedb/lancedb-win32-arm64-msvc@0.27.2': resolution: {integrity: sha512-4ffpFvh49MiUtkdFJOmBytXEbgUPXORphTOuExnJAgT1VAKwQcu4ZzdsgNoK6mumKBaU+pYQU/MedNkgTzx/Lw==} @@ -2649,35 +2632,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@mariozechner/clipboard-linux-arm64-musl@0.3.3': resolution: {integrity: sha512-o1paj2+zmAQ/LaPS85XJCxhNowNQpxYM2cGY6pWvB5Kqmz6hZjl6CzDg5tbf1hZkn/Em6jpOaE2UtMxKdELBDA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@mariozechner/clipboard-linux-riscv64-gnu@0.3.3': resolution: {integrity: sha512-dkEhE4ekePJwMbBq9HP1//CFMNmDzA/iV9AXqBfvL5CWmmDIRXqh4A3YZt3tWO/HdMerX+xNCEiR7WiOsIG+UA==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@mariozechner/clipboard-linux-x64-gnu@0.3.3': resolution: {integrity: sha512-lT2yANtTLlEtFBIH3uGoRa/CQas/eBoLNi3qr9axQFoRgF4RGPSJ66yHOSnMECBneTIb1Iqv3UxokTfX27CdoQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@mariozechner/clipboard-linux-x64-musl@0.3.3': resolution: {integrity: sha512-saq/MCB0QHK/7ZZLjAZ0QkbY944dyjOsur8gneGCfMitt+GOiE1CU4OUipHC4b6x8UDY9bRLsR4aBaxu22OFPA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@mariozechner/clipboard-win32-arm64-msvc@0.3.3': resolution: {integrity: sha512-cGuvSj0/2X2w983yEcKw+i+r1EBej6ZZIN+fXG3eY2G/HaIQpbXpLvMxKyZ9LKtbZx+Z6q/gELEoSBMLML6BaQ==} @@ -2794,35 +2772,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-arm64-musl@0.1.99': resolution: {integrity: sha512-Z+6nyLdJXWzLPVxi4H6g9TJop4DwN3KSgHWto5JCbZV5/uKoVqcSynPs0tGlUHOoWI8S8tEvJspz51GQkvr07w==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@napi-rs/canvas-linux-riscv64-gnu@0.1.99': resolution: {integrity: sha512-jAnfOUv4IO1l8Levk5t85oVtEBOXLa07KnIUgWo1CDlPxiqpxS3uBfiE38Lvj/CQgHaNF6Nxk/SaemwLgsVJgw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-x64-gnu@0.1.99': resolution: {integrity: sha512-mIkXw3fGmbYyFjSmfWEvty4jN+rwEOmv0+Dy9bRvvTzLYWCgm3RMgUEQVfAKFw96nIRFnyNZiK83KNQaVVFjng==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-x64-musl@0.1.99': resolution: {integrity: sha512-f3Uz2P0RgrtBHISxZqr6yiYXJlTDyCVBumDacxo+4AmSg7z0HiqYZKGWC/gszq3fbPhyQUya1W2AEteKxT9Y6A==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@napi-rs/canvas-win32-arm64-msvc@0.1.99': resolution: {integrity: sha512-XE6KUkfqRsCNejcoRMiMr3RaUeObxNf6y7dut3hrq2rn7PzfRTZgrjF1F/B2C7FcdgqY/vSHWpQeMuNz1vTNHg==} @@ -3101,56 +3074,48 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.46.0': resolution: {integrity: sha512-aAUPBWJ1lGwwnxZUEDLJ94+Iy6MuwJwPxUgO4sCA5mEEyDk7b+cDQ+JpX1VR150Zoyd+D49gsrUzpUK5h587Eg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.46.0': resolution: {integrity: sha512-ufBCJukyFX/UDrokP/r6BGDoTInnsDs7bxyzKAgMiZlt2Qu8GPJSJ6Zm6whIiJzKk0naxA8ilwmbO1LMw6Htxw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.46.0': resolution: {integrity: sha512-eqtlC2YmPqjun76R1gVfGLuKWx7NuEnLEAudZ7n6ipSKbCZTqIKSs1b5Y8K/JHZsRpLkeSmAAjig5HOIg8fQzQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.46.0': resolution: {integrity: sha512-yccVOO2nMXkQLGgy0He3EQEwKD7NF0zEk+/OWmroznkqXyJdN6bfK0LtNnr6/14Bh3FjpYq7bP33l/VloCnxpA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.46.0': resolution: {integrity: sha512-aAf7fG23OQCey6VRPj9IeCraoYtpgtx0ZyJ1CXkPyT1wjzBE7c3xtuxHe/AdHaJfVVb/SXpSk8Gl1LzyQupSqw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.46.0': resolution: {integrity: sha512-q0JPsTMyJNjYrBvYFDz4WbVsafNZaPCZv4RnFypRotLqpKROtBZcEaXQW4eb9YmvLU3NckVemLJnzkSZSdmOxw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.46.0': resolution: {integrity: sha512-7LsLY9Cw57GPkhSR+duI3mt9baRczK/DtHYSldQ4BEU92da9igBQNl4z7Vq5U9NNPsh1FmpKvv1q9WDtiUQR1A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxfmt/binding-openharmony-arm64@0.46.0': resolution: {integrity: sha512-lHiBOz8Duaku7JtRNLlps3j++eOaICPZSd8FCVmTDM4DFOPT71Bjn7g6iar1z7StXlKRweUKxWUs4sA+zWGDXg==} @@ -3253,56 +3218,48 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.61.0': resolution: {integrity: sha512-bl1dQh8LnVqsj6oOQAcxwbuOmNJkwc4p6o//HTBZhNTzJy21TLDwAviMqUFNUxDHkPGpmdKTSN4tWTjLryP8xg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.61.0': resolution: {integrity: sha512-QoOX6KB2IiEpyOj/HKqaxi+NQHPnOgNgnr22n9N4ANJCzXkUlj1UmeAbFb4PpqdlHIzvGDM5xZ0OKtcLq9RhiQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.61.0': resolution: {integrity: sha512-1TGcTerjY6p152wCof3oKElccq3xHljS/Mucp04gV/4ATpP6nO7YNnp7opEg6SHkv2a57/b4b8Ndm9znJ1/qAw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.61.0': resolution: {integrity: sha512-65wXEmZIrX2ADwC8i/qFL4EWLSbeuBpAm3suuX1vu4IQkKd+wLT/HU/BOl84kp91u2SxPkPDyQgu4yrqp8vwVA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.61.0': resolution: {integrity: sha512-TVvhgMvor7Qa6COeXxCJ7ENOM+lcAOGsQ0iUdPSCv2hxb9qSHLQ4XF1h50S6RE1gBOJ0WV3rNukg4JJJP1LWRA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.61.0': resolution: {integrity: sha512-SjpS5uYuFoDnDdZPwZE59ndF95AsY47R5MliuneTWR1pDm2CxGJaYXbKULI71t5TVfLQUWmrHEGRL9xvuq6dnA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-x64-musl@1.61.0': resolution: {integrity: sha512-gGfAeGD4sNJGILZbc/yKcIimO9wQnPMoYp9swAaKeEtwsSQAbU+rsdQze5SBtIP6j0QDzeYd4XSSUCRCF+LIeQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxlint/binding-openharmony-arm64@1.61.0': resolution: {integrity: sha512-OlVT0LrG/ct33EVtWRyR+B/othwmDWeRxfi13wUdPeb3lAT5TgTcFDcfLfarZtzB4W1nWF/zICMgYdkggX2WmQ==} @@ -3446,84 +3403,72 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.16': resolution: {integrity: sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16': resolution: {integrity: sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16': resolution: {integrity: sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.16': resolution: {integrity: sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.16': resolution: {integrity: sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.16': resolution: {integrity: sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==} @@ -3892,28 +3837,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@snazzah/davey-linux-arm64-musl@0.1.11': resolution: {integrity: sha512-e6pX6Hiabtz99q+H/YHNkm9JVlpqN8HGh0qPib8G2+UY4/SSH8WvqWipk3v581dMy2oyCHt7MOoY1aU1P1N/xA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@snazzah/davey-linux-x64-gnu@0.1.11': resolution: {integrity: sha512-TW5bSoqChOJMbvsDb4wAATYrxmAXuNnse7wFNVSAJUaZKSeRfZbu3UAiPWSNn7GwLwSfU6hg322KZUn8IWCuvg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@snazzah/davey-linux-x64-musl@0.1.11': resolution: {integrity: sha512-5j6Pmc+Wzv5lSxVP6quA7teYRJXibkZqQyYGfTDnTsUOO5dPpcojpqlXlkhyvsA1OAQTj4uxbOCciN3cVWwzug==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@snazzah/davey-wasm32-wasi@0.1.11': resolution: {integrity: sha512-rKOwZ/0J8lp+4VEyOdMDBRP9KR+PksZpa9V1Qn0veMzy4FqTVKthkxwGqewheFe0SFg9fdvt798l/PBFrfDeZw==} @@ -4394,6 +4335,9 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + assert-never@1.4.0: resolution: {integrity: sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==} @@ -4532,6 +4476,9 @@ packages: bmp-ts@1.0.9: resolution: {integrity: sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw==} + bn.js@4.12.3: + resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -5380,6 +5327,10 @@ packages: resolution: {integrity: sha512-FcF8VhXYLQcxWCnt/cCpT2apKsRDUGeVEeMqGu4HSTu29U8Yw0TLOjdYIlDsYk3IkUh+taX4IDWpPcCqKDhCjA==} engines: {node: '>= 20'} + http_ece@1.2.0: + resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==} + engines: {node: '>=16'} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -5686,28 +5637,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -6033,10 +5980,16 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + minimatch@10.2.4: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -7363,6 +7316,11 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + 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'} @@ -11104,6 +11062,13 @@ snapshots: asap@2.0.6: {} + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.3 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + assert-never@1.4.0: {} assertion-error@2.0.1: {} @@ -11227,6 +11192,8 @@ snapshots: bmp-ts@1.0.9: {} + bn.js@4.12.3: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -12225,6 +12192,8 @@ snapshots: transitivePeerDependencies: - supports-color + http_ece@1.2.0: {} + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -13100,10 +13069,14 @@ snapshots: mimic-fn@2.1.0: {} + minimalistic-assert@1.0.1: {} + minimatch@10.2.4: dependencies: brace-expansion: 5.0.5 + minimist@1.2.8: {} + minipass@7.1.3: {} minizlib@3.1.0: @@ -14577,6 +14550,16 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + 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: {} webidl-conversions@3.0.1: {} diff --git a/src/gateway/control-ui-csp.ts b/src/gateway/control-ui-csp.ts index 1131e95d41a..08caa928167 100644 --- a/src/gateway/control-ui-csp.ts +++ b/src/gateway/control-ui-csp.ts @@ -46,6 +46,7 @@ export function buildControlUiCspHeader(opts?: { inlineScriptHashes?: string[] } "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "img-src 'self' data: blob:", "font-src 'self' https://fonts.gstatic.com", + "worker-src 'self'", "connect-src 'self' ws: wss:", ].join("; "); } diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 888d0b682bb..a939e4f282f 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -103,6 +103,8 @@ function contentTypeForExt(ext: string): string { return "image/x-icon"; case ".txt": return "text/plain; charset=utf-8"; + case ".webmanifest": + return "application/manifest+json; charset=utf-8"; default: return "application/octet-stream"; } @@ -128,6 +130,7 @@ const STATIC_ASSET_EXTENSIONS = new Set([ ".webp", ".ico", ".txt", + ".webmanifest", ]); export type ControlUiAvatarResolution = diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 8c1c8332bdc..8cd32af9d32 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -146,6 +146,10 @@ const METHOD_SCOPE_GROUPS: Record = { "doctor.memory.repairDreamingArtifacts", "doctor.memory.dedupeDreamDiary", "push.test", + "push.web.vapidPublicKey", + "push.web.subscribe", + "push.web.unsubscribe", + "push.web.test", "node.pending.enqueue", ], [ADMIN_SCOPE]: [ diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 908b95363a9..23ccb80337a 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -197,6 +197,14 @@ import { type PushTestParams, PushTestParamsSchema, PushTestResultSchema, + type WebPushVapidPublicKeyParams, + WebPushVapidPublicKeyParamsSchema, + type WebPushSubscribeParams, + WebPushSubscribeParamsSchema, + type WebPushUnsubscribeParams, + WebPushUnsubscribeParamsSchema, + type WebPushTestParams, + WebPushTestParamsSchema, type PresenceEntry, PresenceEntrySchema, ProtocolSchemas, @@ -366,6 +374,16 @@ export const validateNodePendingEnqueueParams = ajv.compile(PushTestParamsSchema); +export const validateWebPushVapidPublicKeyParams = ajv.compile( + WebPushVapidPublicKeyParamsSchema, +); +export const validateWebPushSubscribeParams = ajv.compile( + WebPushSubscribeParamsSchema, +); +export const validateWebPushUnsubscribeParams = ajv.compile( + WebPushUnsubscribeParamsSchema, +); +export const validateWebPushTestParams = ajv.compile(WebPushTestParamsSchema); export const validateSecretsResolveParams = ajv.compile( SecretsResolveParamsSchema, ); @@ -581,6 +599,10 @@ export { WakeParamsSchema, PushTestParamsSchema, PushTestResultSchema, + WebPushVapidPublicKeyParamsSchema, + WebPushSubscribeParamsSchema, + WebPushUnsubscribeParamsSchema, + WebPushTestParamsSchema, NodePairRequestParamsSchema, NodePairListParamsSchema, NodePairApproveParamsSchema, @@ -812,6 +834,10 @@ export type { LogsTailParams, LogsTailResult, PollParams, + WebPushVapidPublicKeyParams, + WebPushSubscribeParams, + WebPushUnsubscribeParams, + WebPushTestParams, UpdateRunParams, ChatInjectParams, }; diff --git a/src/gateway/protocol/schema/push.ts b/src/gateway/protocol/schema/push.ts index 21b87876ae3..c4276166445 100644 --- a/src/gateway/protocol/schema/push.ts +++ b/src/gateway/protocol/schema/push.ts @@ -26,3 +26,51 @@ export const PushTestResultSchema = Type.Object( }, { additionalProperties: false }, ); + +// --- Web Push schemas --- + +const WebPushKeysSchema = Type.Object( + { + p256dh: Type.String({ minLength: 1, maxLength: 512 }), + auth: Type.String({ minLength: 1, maxLength: 512 }), + }, + { additionalProperties: false }, +); + +export const WebPushVapidPublicKeyParamsSchema = Type.Object({}, { additionalProperties: false }); + +export const WebPushSubscribeParamsSchema = Type.Object( + { + endpoint: Type.String({ minLength: 1, maxLength: 2048, pattern: "^https://" }), + keys: WebPushKeysSchema, + }, + { additionalProperties: false }, +); + +export const WebPushUnsubscribeParamsSchema = Type.Object( + { + endpoint: Type.String({ minLength: 1, maxLength: 2048, pattern: "^https://" }), + }, + { additionalProperties: false }, +); + +export const WebPushTestParamsSchema = Type.Object( + { + title: Type.Optional(Type.String()), + body: Type.Optional(Type.String()), + }, + { additionalProperties: false }, +); + +export type WebPushVapidPublicKeyParams = Record; +export type WebPushSubscribeParams = { + endpoint: string; + keys: { p256dh: string; auth: string }; +}; +export type WebPushUnsubscribeParams = { + endpoint: string; +}; +export type WebPushTestParams = { + title?: string; + body?: string; +}; diff --git a/src/gateway/server-methods/push.ts b/src/gateway/server-methods/push.ts index 44ebdf3e4aa..fe114b119f1 100644 --- a/src/gateway/server-methods/push.ts +++ b/src/gateway/server-methods/push.ts @@ -8,8 +8,22 @@ import { sendApnsAlert, shouldClearStoredApnsRegistration, } from "../../infra/push-apns.js"; +import { + broadcastWebPush, + clearWebPushSubscriptionByEndpoint, + registerWebPushSubscription, + resolveVapidKeys, +} from "../../infra/push-web.js"; import { normalizeStringifiedOptionalString } from "../../shared/string-coerce.js"; -import { ErrorCodes, errorShape, validatePushTestParams } from "../protocol/index.js"; +import { + ErrorCodes, + errorShape, + validatePushTestParams, + validateWebPushSubscribeParams, + validateWebPushTestParams, + validateWebPushUnsubscribeParams, + validateWebPushVapidPublicKeyParams, +} from "../protocol/index.js"; import { respondInvalidParams, respondUnavailableOnThrow } from "./nodes.helpers.js"; import { normalizeTrimmedString } from "./record-shared.js"; import type { GatewayRequestHandlers } from "./types.js"; @@ -100,4 +114,82 @@ export const pushHandlers: GatewayRequestHandlers = { respond(true, result, undefined); }); }, + + "push.web.vapidPublicKey": async ({ params, respond }) => { + if (!validateWebPushVapidPublicKeyParams(params)) { + respondInvalidParams({ + respond, + method: "push.web.vapidPublicKey", + validator: validateWebPushVapidPublicKeyParams, + }); + return; + } + + await respondUnavailableOnThrow(respond, async () => { + const vapid = await resolveVapidKeys(); + respond(true, { vapidPublicKey: vapid.publicKey }, undefined); + }); + }, + + "push.web.subscribe": async ({ params, respond }) => { + if (!validateWebPushSubscribeParams(params)) { + respondInvalidParams({ + respond, + method: "push.web.subscribe", + validator: validateWebPushSubscribeParams, + }); + return; + } + + await respondUnavailableOnThrow(respond, async () => { + const subscription = await registerWebPushSubscription({ + endpoint: params.endpoint, + keys: params.keys, + }); + respond(true, { subscriptionId: subscription.subscriptionId }, undefined); + }); + }, + + "push.web.unsubscribe": async ({ params, respond }) => { + if (!validateWebPushUnsubscribeParams(params)) { + respondInvalidParams({ + respond, + method: "push.web.unsubscribe", + validator: validateWebPushUnsubscribeParams, + }); + return; + } + + await respondUnavailableOnThrow(respond, async () => { + const removed = await clearWebPushSubscriptionByEndpoint(params.endpoint); + respond(true, { removed }, undefined); + }); + }, + + "push.web.test": async ({ params, respond }) => { + if (!validateWebPushTestParams(params)) { + respondInvalidParams({ + respond, + method: "push.web.test", + validator: validateWebPushTestParams, + }); + return; + } + + const title = normalizeTrimmedString(params.title) ?? "OpenClaw"; + const body = normalizeTrimmedString(params.body) ?? "Web push test notification"; + + await respondUnavailableOnThrow(respond, async () => { + const results = await broadcastWebPush({ title, body }); + if (results.length === 0) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "no web push subscriptions registered"), + ); + return; + } + respond(true, { results }, undefined); + }); + }, }; diff --git a/src/infra/push-web.test.ts b/src/infra/push-web.test.ts new file mode 100644 index 00000000000..e043a04869f --- /dev/null +++ b/src/infra/push-web.test.ts @@ -0,0 +1,227 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import webPush from "web-push"; +import { + broadcastWebPush, + clearWebPushSubscription, + clearWebPushSubscriptionByEndpoint, + listWebPushSubscriptions, + loadWebPushSubscription, + registerWebPushSubscription, + resolveVapidKeys, + sendWebPushNotification, +} from "./push-web.js"; + +// Stub resolveStateDir so tests use a temp directory. +let tmpDir: string; +vi.mock("../config/paths.js", () => ({ + resolveStateDir: () => tmpDir, +})); + +// Stub web-push so we don't make real HTTP requests. +vi.mock("web-push", () => ({ + default: { + generateVAPIDKeys: () => ({ + publicKey: "test-public-key-base64url", + privateKey: "test-private-key-base64url", + }), + setVapidDetails: vi.fn(), + sendNotification: vi.fn().mockResolvedValue({ statusCode: 201 }), + }, +})); + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "push-web-test-")); + vi.clearAllMocks(); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe("resolveVapidKeys", () => { + it("generates and persists VAPID keys on first call", async () => { + const keys = await resolveVapidKeys(tmpDir); + expect(keys.publicKey).toBe("test-public-key-base64url"); + expect(keys.privateKey).toBe("test-private-key-base64url"); + expect(keys.subject).toMatch(/^mailto:/); + + // Second call returns same keys. + const keys2 = await resolveVapidKeys(tmpDir); + expect(keys2.publicKey).toBe(keys.publicKey); + expect(keys2.privateKey).toBe(keys.privateKey); + }); + + it("prefers env vars over persisted keys", async () => { + // Persist keys first. + await resolveVapidKeys(tmpDir); + + // Set env overrides. + process.env.OPENCLAW_VAPID_PUBLIC_KEY = "env-public"; + process.env.OPENCLAW_VAPID_PRIVATE_KEY = "env-private"; + process.env.OPENCLAW_VAPID_SUBJECT = "mailto:env@test.com"; + try { + const keys = await resolveVapidKeys(tmpDir); + expect(keys.publicKey).toBe("env-public"); + expect(keys.privateKey).toBe("env-private"); + expect(keys.subject).toBe("mailto:env@test.com"); + } finally { + delete process.env.OPENCLAW_VAPID_PUBLIC_KEY; + delete process.env.OPENCLAW_VAPID_PRIVATE_KEY; + delete process.env.OPENCLAW_VAPID_SUBJECT; + } + }); +}); + +describe("subscription CRUD", () => { + const endpoint = "https://push.example.com/send/abc123"; + const keys = { p256dh: "p256dh-key", auth: "auth-key" }; + + it("registers a new subscription", async () => { + const sub = await registerWebPushSubscription({ + endpoint, + keys, + baseDir: tmpDir, + }); + expect(sub.subscriptionId).toBeTruthy(); + expect(sub.endpoint).toBe(endpoint); + expect(sub.keys.p256dh).toBe("p256dh-key"); + expect(sub.keys.auth).toBe("auth-key"); + expect(sub.createdAtMs).toBeGreaterThan(0); + }); + + it("updates an existing subscription with the same endpoint", async () => { + const sub1 = await registerWebPushSubscription({ + endpoint, + keys, + baseDir: tmpDir, + }); + const sub2 = await registerWebPushSubscription({ + endpoint, + keys: { p256dh: "new-p256dh", auth: "new-auth" }, + baseDir: tmpDir, + }); + // Same subscription ID, same created time, updated keys. + expect(sub2.subscriptionId).toBe(sub1.subscriptionId); + expect(sub2.createdAtMs).toBe(sub1.createdAtMs); + expect(sub2.keys.p256dh).toBe("new-p256dh"); + }); + + it("loads a subscription by ID", async () => { + const sub = await registerWebPushSubscription({ + endpoint, + keys, + baseDir: tmpDir, + }); + const loaded = await loadWebPushSubscription(sub.subscriptionId, tmpDir); + expect(loaded).not.toBeNull(); + expect(loaded!.endpoint).toBe(endpoint); + }); + + it("returns null for unknown subscription ID", async () => { + const loaded = await loadWebPushSubscription("nonexistent", tmpDir); + expect(loaded).toBeNull(); + }); + + it("lists all subscriptions", async () => { + await registerWebPushSubscription({ + endpoint: "https://push.example.com/a", + keys, + baseDir: tmpDir, + }); + await registerWebPushSubscription({ + endpoint: "https://push.example.com/b", + keys, + baseDir: tmpDir, + }); + const list = await listWebPushSubscriptions(tmpDir); + expect(list).toHaveLength(2); + }); + + it("clears a subscription by ID", async () => { + const sub = await registerWebPushSubscription({ + endpoint, + keys, + baseDir: tmpDir, + }); + const removed = await clearWebPushSubscription(sub.subscriptionId, tmpDir); + expect(removed).toBe(true); + + const list = await listWebPushSubscriptions(tmpDir); + expect(list).toHaveLength(0); + }); + + it("clears a subscription by endpoint", async () => { + await registerWebPushSubscription({ endpoint, keys, baseDir: tmpDir }); + const removed = await clearWebPushSubscriptionByEndpoint(endpoint, tmpDir); + expect(removed).toBe(true); + + const list = await listWebPushSubscriptions(tmpDir); + expect(list).toHaveLength(0); + }); + + it("rejects invalid endpoint", async () => { + await expect( + registerWebPushSubscription({ + endpoint: "http://insecure.example.com", + keys, + baseDir: tmpDir, + }), + ).rejects.toThrow("invalid push subscription endpoint"); + }); + + it("rejects empty keys", async () => { + await expect( + registerWebPushSubscription({ + endpoint, + keys: { p256dh: "", auth: "auth-key" }, + baseDir: tmpDir, + }), + ).rejects.toThrow("invalid push subscription keys"); + }); +}); + +describe("sending", () => { + const keys = { p256dh: "p256dh-key", auth: "auth-key" }; + + it("configures VAPID details for direct sends", async () => { + const sub = await registerWebPushSubscription({ + endpoint: "https://push.example.com/direct", + keys, + baseDir: tmpDir, + }); + + const result = await sendWebPushNotification(sub, { title: "Direct" }); + + expect(result.ok).toBe(true); + expect(vi.mocked(webPush.setVapidDetails)).toHaveBeenCalledTimes(1); + expect(vi.mocked(webPush.setVapidDetails)).toHaveBeenCalledWith( + "mailto:openclaw@localhost", + "test-public-key-base64url", + "test-private-key-base64url", + ); + expect(vi.mocked(webPush.sendNotification)).toHaveBeenCalledTimes(1); + }); + + it("configures VAPID details once before broadcasting to subscribers", async () => { + await registerWebPushSubscription({ + endpoint: "https://push.example.com/a", + keys, + baseDir: tmpDir, + }); + await registerWebPushSubscription({ + endpoint: "https://push.example.com/b", + keys, + baseDir: tmpDir, + }); + + const results = await broadcastWebPush({ title: "Broadcast" }, tmpDir); + + expect(results).toHaveLength(2); + expect(results.every((result) => result.ok)).toBe(true); + expect(vi.mocked(webPush.setVapidDetails)).toHaveBeenCalledTimes(1); + expect(vi.mocked(webPush.sendNotification)).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/infra/push-web.ts b/src/infra/push-web.ts new file mode 100644 index 00000000000..135f575d434 --- /dev/null +++ b/src/infra/push-web.ts @@ -0,0 +1,334 @@ +import { createHash, randomUUID } from "node:crypto"; +import path from "node:path"; +import webPush from "web-push"; +import { resolveStateDir } from "../config/paths.js"; +import { createAsyncLock, readJsonFile, writeJsonAtomic } from "./json-files.js"; + +// --- Types --- + +export type WebPushSubscription = { + subscriptionId: string; + endpoint: string; + keys: { p256dh: string; auth: string }; + createdAtMs: number; + updatedAtMs: number; +}; + +export type WebPushRegistrationState = { + subscriptionsByEndpointHash: Record; +}; + +export type VapidKeyPair = { + publicKey: string; + privateKey: string; + subject: string; +}; + +export type WebPushSendResult = { + ok: boolean; + subscriptionId: string; + statusCode?: number; + error?: string; +}; + +// --- Constants --- + +const WEB_PUSH_STATE_FILENAME = "push/web-push-subscriptions.json"; +const VAPID_KEYS_FILENAME = "push/vapid-keys.json"; +const MAX_ENDPOINT_LENGTH = 2048; +const MAX_KEY_LENGTH = 512; +const DEFAULT_VAPID_SUBJECT = "mailto:openclaw@localhost"; + +const withLock = createAsyncLock(); + +// --- Helpers --- + +function resolveWebPushStatePath(baseDir?: string): string { + const root = baseDir ?? resolveStateDir(); + return path.join(root, WEB_PUSH_STATE_FILENAME); +} + +function resolveVapidKeysPath(baseDir?: string): string { + const root = baseDir ?? resolveStateDir(); + return path.join(root, VAPID_KEYS_FILENAME); +} + +function hashEndpoint(endpoint: string): string { + return createHash("sha256").update(endpoint).digest("hex").slice(0, 32); +} + +function isValidEndpoint(endpoint: string): boolean { + if (!endpoint || endpoint.length > MAX_ENDPOINT_LENGTH) { + return false; + } + try { + const url = new URL(endpoint); + return url.protocol === "https:"; + } catch { + return false; + } +} + +function isValidKey(key: string): boolean { + return typeof key === "string" && key.length > 0 && key.length <= MAX_KEY_LENGTH; +} + +// --- State persistence --- + +async function loadState(baseDir?: string): Promise { + const filePath = resolveWebPushStatePath(baseDir); + const state = await readJsonFile(filePath); + return state ?? { subscriptionsByEndpointHash: {} }; +} + +async function persistState(state: WebPushRegistrationState, baseDir?: string): Promise { + const filePath = resolveWebPushStatePath(baseDir); + await writeJsonAtomic(filePath, state, { trailingNewline: true }); +} + +// --- VAPID keys --- + +export async function resolveVapidKeys(baseDir?: string): Promise { + // Env vars take precedence — allows operators to share a stable VAPID + // identity across multiple gateway instances. + const envPublic = resolveVapidPublicKeyFromEnv(); + const envPrivate = resolveVapidPrivateKeyFromEnv(); + if (envPublic && envPrivate) { + return { + publicKey: envPublic, + privateKey: envPrivate, + subject: resolveVapidSubjectFromEnv(), + }; + } + + // Fall back to persisted keys, generating on first use under a lock to + // prevent concurrent bootstraps from writing different keypairs. + return await withLock(async () => { + const filePath = resolveVapidKeysPath(baseDir); + const existing = await readJsonFile(filePath); + if (existing?.publicKey && existing?.privateKey) { + return { + publicKey: existing.publicKey, + privateKey: existing.privateKey, + // Env var always wins so operators can change subject without deleting vapid-keys.json. + subject: resolveVapidSubjectFromEnv(), + }; + } + + const keys = webPush.generateVAPIDKeys(); + const pair: VapidKeyPair = { + publicKey: keys.publicKey, + privateKey: keys.privateKey, + subject: resolveVapidSubjectFromEnv(), + }; + await writeJsonAtomic(filePath, pair, { trailingNewline: true }); + return pair; + }); +} + +function resolveVapidSubjectFromEnv(): string { + return process.env.OPENCLAW_VAPID_SUBJECT || DEFAULT_VAPID_SUBJECT; +} + +export function resolveVapidPublicKeyFromEnv(): string | undefined { + return process.env.OPENCLAW_VAPID_PUBLIC_KEY || undefined; +} + +export function resolveVapidPrivateKeyFromEnv(): string | undefined { + return process.env.OPENCLAW_VAPID_PRIVATE_KEY || undefined; +} + +// --- Subscription CRUD --- + +export type RegisterWebPushParams = { + endpoint: string; + keys: { p256dh: string; auth: string }; + baseDir?: string; +}; + +export async function registerWebPushSubscription( + params: RegisterWebPushParams, +): Promise { + const { endpoint, keys, baseDir } = params; + + if (!isValidEndpoint(endpoint)) { + throw new Error("invalid push subscription endpoint: must be an HTTPS URL under 2048 chars"); + } + if (!isValidKey(keys.p256dh) || !isValidKey(keys.auth)) { + throw new Error("invalid push subscription keys: must be non-empty strings under 512 chars"); + } + + return await withLock(async () => { + const state = await loadState(baseDir); + const hash = hashEndpoint(endpoint); + const now = Date.now(); + + const existing = state.subscriptionsByEndpointHash[hash]; + const subscription: WebPushSubscription = { + subscriptionId: existing?.subscriptionId ?? randomUUID(), + endpoint, + keys: { p256dh: keys.p256dh, auth: keys.auth }, + createdAtMs: existing?.createdAtMs ?? now, + updatedAtMs: now, + }; + + state.subscriptionsByEndpointHash[hash] = subscription; + await persistState(state, baseDir); + return subscription; + }); +} + +export async function loadWebPushSubscription( + subscriptionId: string, + baseDir?: string, +): Promise { + const state = await loadState(baseDir); + for (const sub of Object.values(state.subscriptionsByEndpointHash)) { + if (sub.subscriptionId === subscriptionId) { + return sub; + } + } + return null; +} + +export async function listWebPushSubscriptions(baseDir?: string): Promise { + const state = await loadState(baseDir); + return Object.values(state.subscriptionsByEndpointHash); +} + +export async function clearWebPushSubscription( + subscriptionId: string, + baseDir?: string, +): Promise { + return await withLock(async () => { + const state = await loadState(baseDir); + for (const [hash, sub] of Object.entries(state.subscriptionsByEndpointHash)) { + if (sub.subscriptionId === subscriptionId) { + delete state.subscriptionsByEndpointHash[hash]; + await persistState(state, baseDir); + return true; + } + } + return false; + }); +} + +export async function clearWebPushSubscriptionByEndpoint( + endpoint: string, + baseDir?: string, +): Promise { + return await withLock(async () => { + const state = await loadState(baseDir); + const hash = hashEndpoint(endpoint); + if (state.subscriptionsByEndpointHash[hash]) { + delete state.subscriptionsByEndpointHash[hash]; + await persistState(state, baseDir); + return true; + } + return false; + }); +} + +// --- Sending --- + +export type WebPushPayload = { + title: string; + body?: string; + tag?: string; + url?: string; +}; + +function applyVapidDetails(keys: VapidKeyPair): void { + webPush.setVapidDetails(keys.subject, keys.publicKey, keys.privateKey); +} + +export async function sendWebPushNotification( + subscription: WebPushSubscription, + payload: WebPushPayload, + vapidKeys?: VapidKeyPair, +): Promise { + const keys = vapidKeys ?? (await resolveVapidKeys()); + applyVapidDetails(keys); + + return sendPreparedWebPushNotification(subscription, payload); +} + +async function sendPreparedWebPushNotification( + subscription: WebPushSubscription, + payload: WebPushPayload, +): Promise { + const pushSubscription = { + endpoint: subscription.endpoint, + keys: { + p256dh: subscription.keys.p256dh, + auth: subscription.keys.auth, + }, + }; + + try { + const result = await webPush.sendNotification(pushSubscription, JSON.stringify(payload)); + return { + ok: true, + subscriptionId: subscription.subscriptionId, + statusCode: result.statusCode, + }; + } catch (err: unknown) { + const statusCode = + typeof err === "object" && err !== null && "statusCode" in err + ? (err as { statusCode: number }).statusCode + : undefined; + const message = + typeof err === "object" && err !== null && "message" in err + ? (err as { message: string }).message + : "unknown error"; + return { + ok: false, + subscriptionId: subscription.subscriptionId, + statusCode, + error: message, + }; + } +} + +export async function broadcastWebPush( + payload: WebPushPayload, + baseDir?: string, +): Promise { + const subscriptions = await listWebPushSubscriptions(baseDir); + if (subscriptions.length === 0) { + return []; + } + + const vapidKeys = await resolveVapidKeys(baseDir); + + // Set VAPID details once before fanning out concurrent sends. + applyVapidDetails(vapidKeys); + + const results = await Promise.allSettled( + subscriptions.map((sub) => sendPreparedWebPushNotification(sub, payload)), + ); + + const mapped = results.map((r, i) => + r.status === "fulfilled" + ? r.value + : { + ok: false, + subscriptionId: subscriptions[i].subscriptionId, + error: r.reason instanceof Error ? r.reason.message : "unknown error", + }, + ); + + // Clean up expired subscriptions (HTTP 410 Gone or 404 Not Found) per Web Push spec. + const expiredEndpoints = mapped + .map((result, i) => ({ result, sub: subscriptions[i] })) + .filter(({ result }) => !result.ok && (result.statusCode === 410 || result.statusCode === 404)) + .map(({ sub }) => sub.endpoint); + + if (expiredEndpoints.length > 0) { + await Promise.allSettled( + expiredEndpoints.map((endpoint) => clearWebPushSubscriptionByEndpoint(endpoint, baseDir)), + ); + } + + return mapped; +} diff --git a/src/types/web-push.d.ts b/src/types/web-push.d.ts new file mode 100644 index 00000000000..c1c742f79a9 --- /dev/null +++ b/src/types/web-push.d.ts @@ -0,0 +1,30 @@ +declare module "web-push" { + export type PushSubscription = { + endpoint: string; + keys: { + p256dh: string; + auth: string; + }; + }; + + export type SendResult = { + statusCode: number; + body: string; + headers: Record; + }; + + export type VAPIDKeys = { + publicKey: string; + privateKey: string; + }; + + export function generateVAPIDKeys(): VAPIDKeys; + + export function setVapidDetails(subject: string, publicKey: string, privateKey: string): void; + + export function sendNotification( + subscription: PushSubscription, + payload?: string | Buffer | null, + options?: Record, + ): Promise; +} diff --git a/ui/index.html b/ui/index.html index a36c6850158..274d4a96eba 100644 --- a/ui/index.html +++ b/ui/index.html @@ -8,6 +8,7 @@ +