diff --git a/.env.business.example b/.env.business.example index 5f0fbc4..c130fcc 100644 --- a/.env.business.example +++ b/.env.business.example @@ -102,9 +102,14 @@ EMAIL_FROM_NAME="" EMAIL_REPLY_TO="" # ── Web Push (optional — defaults to logging to the console instead of sending) ─ -# Generate with: npx web-push generate-vapid-keys +# Managed centrally by the Manager's Settings > Push Notifications page and +# pushed into every business's .env automatically — see manager/src/lib/ +# global-settings.ts. There's normally no reason to hand-edit these for +# one business only; this by-hand template still needs them set if you're +# not using the Manager. No NEXT_PUBLIC_VAPID_PUBLIC_KEY here — the app +# serves its public key at runtime via GET /api/push/vapid-public-key +# instead (see that route's own header for why). PUSH_PROVIDER="console" VAPID_PUBLIC_KEY="" -NEXT_PUBLIC_VAPID_PUBLIC_KEY="" VAPID_PRIVATE_KEY="" VAPID_SUBJECT="mailto:owner@example.com" diff --git a/.env.example b/.env.example index e62e65d..3230702 100644 --- a/.env.example +++ b/.env.example @@ -55,13 +55,17 @@ EMAIL_FROM_NAME="" # Optional — a distinct reply-to address, separate from the verified sender address. EMAIL_REPLY_TO="" -# Web Push (Phase 7) — generate with: npx web-push generate-vapid-keys -# NEXT_PUBLIC_VAPID_PUBLIC_KEY must be the same value as VAPID_PUBLIC_KEY — -# it's duplicated because the browser needs it for pushManager.subscribe. +# Web Push — generate with: npx web-push generate-vapid-keys +# On a multi-business deployment these are normally managed centrally by +# the Manager's Settings > Push Notifications page and pushed into every +# business's .env automatically — you don't need to fill these in by hand +# there. This single-business template still needs them set. The public +# key reaches the browser via GET /api/push/vapid-public-key at runtime, +# not a NEXT_PUBLIC_* build-time variable — see that route's own header +# for why (every business shares one prebuilt image). # PUSH_PROVIDER="web-push" to actually send; defaults to "console" (logs only). PUSH_PROVIDER="console" VAPID_PUBLIC_KEY="" -NEXT_PUBLIC_VAPID_PUBLIC_KEY="" VAPID_PRIVATE_KEY="" VAPID_SUBJECT="mailto:owner@example.com" diff --git a/manager/package-lock.json b/manager/package-lock.json index 7bc4a4b..14f7b8f 100644 --- a/manager/package-lock.json +++ b/manager/package-lock.json @@ -13,7 +13,8 @@ "next": "^16.2.12", "nodemailer": "^8.0.11", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "web-push": "^3.6.7" }, "devDependencies": { "@tailwindcss/postcss": "^4.3.3", @@ -22,6 +23,7 @@ "@types/nodemailer": "^8.0.1", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", + "@types/web-push": "^3.6.4", "eslint": "^9.39.5", "eslint-config-next": "^16.2.12", "tailwindcss": "^4.3.3", @@ -1765,6 +1767,16 @@ "@types/react": "^19.2.0" } }, + "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/@typescript-eslint/eslint-plugin": { "version": "8.69.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz", @@ -2437,6 +2449,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "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/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -2647,6 +2668,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -2728,6 +2761,12 @@ "bcrypt": "bin/bcrypt" } }, + "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/brace-expansion": { "version": "1.1.18", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", @@ -2786,6 +2825,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "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/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -3010,7 +3055,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3114,6 +3158,15 @@ "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/electron-to-chromium": { "version": "1.5.422", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", @@ -4220,6 +4273,28 @@ "hermes-estree": "0.25.1" } }, + "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/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/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -4273,6 +4348,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4854,6 +4935,27 @@ "node": ">=4.0" } }, + "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/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5286,6 +5388,12 @@ "node": ">=8.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/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -5303,7 +5411,6 @@ "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" @@ -5313,7 +5420,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -5990,6 +6096,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -6834,6 +6960,25 @@ "punycode": "^2.1.0" } }, + "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/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/manager/package.json b/manager/package.json index cba2d95..215663a 100644 --- a/manager/package.json +++ b/manager/package.json @@ -16,7 +16,8 @@ "next": "^16.2.12", "nodemailer": "^8.0.11", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "web-push": "^3.6.7" }, "devDependencies": { "@tailwindcss/postcss": "^4.3.3", @@ -25,6 +26,7 @@ "@types/nodemailer": "^8.0.1", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", + "@types/web-push": "^3.6.4", "eslint": "^9.39.5", "eslint-config-next": "^16.2.12", "tailwindcss": "^4.3.3", diff --git a/manager/src/app/dashboard/settings/actions.ts b/manager/src/app/dashboard/settings/actions.ts index 03f3b19..b17c667 100644 --- a/manager/src/app/dashboard/settings/actions.ts +++ b/manager/src/app/dashboard/settings/actions.ts @@ -1,11 +1,17 @@ "use server"; import { applyEmailSettingsToAllBusinesses, type EmailRolloutResult } from "@/lib/email-rollout"; +import { applyPushSettingsToAllBusinesses, type PushRolloutResult } from "@/lib/push-rollout"; import { + generateVapidKeys, isEmailConfigComplete, + isPushConfigComplete, recordLastTestResult, resolveEmailSettings, + resolvePushSettings, saveEmailSettings, + savePushSubject, + VapidKeysAlreadyExistError, type EmailEncryptionMode, type EmailSettingsInput, } from "@/lib/global-settings"; @@ -141,3 +147,94 @@ export async function sendTestEmailAction( return { error: null, result }; } + +// ── Push / VAPID ───────────────────────────────────────────────────────── + +export interface GenerateVapidKeysState { + error: string | null; + needsConfirmation: boolean; + generated: boolean; + rollout: PushRolloutResult[] | null; +} + +/** + * Generates a brand-new VAPID key pair. If one already exists, the first + * call (no `confirmOverwrite` field in the form) is rejected with + * `needsConfirmation: true` instead of an error — the form re-renders a + * warning + a second, explicit "Yes, generate new keys" button (which + * submits again with `confirmOverwrite=true`) rather than ever + * regenerating silently. See global-settings.ts's generateVapidKeys for + * why keys are generate-only (no hand-pasted key fields exist). + */ +export async function generateVapidKeysAction( + _prevState: GenerateVapidKeysState, + formData: FormData, +): Promise { + const subject = String(formData.get("subject") ?? "").trim(); + const confirmOverwrite = formData.get("confirmOverwrite") === "true"; + + if (subject && !/^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/.test(subject)) { + return { error: "Enter a valid contact address, e.g. mailto:support@example.com.", needsConfirmation: false, generated: false, rollout: null }; + } + + let resolved; + try { + resolved = await generateVapidKeys({ subject, confirmOverwrite }); + } catch (error) { + if (error instanceof VapidKeysAlreadyExistError) { + return { error: null, needsConfirmation: true, generated: false, rollout: null }; + } + return { error: error instanceof Error ? error.message : "Couldn't generate VAPID keys.", needsConfirmation: false, generated: false, rollout: null }; + } + + if (!isPushConfigComplete(resolved)) { + // No subject yet — saved as a draft, nothing rolled out (mirrors + // saveEmailSettingsAction's incomplete-config behavior). + return { error: null, needsConfirmation: false, generated: true, rollout: null }; + } + + const rollout = await applyPushSettingsToAllBusinesses(resolved); + return { error: null, needsConfirmation: false, generated: true, rollout }; +} + +export interface SavePushSubjectState { + error: string | null; + saved: boolean; + rollout: PushRolloutResult[] | null; +} + +export async function savePushSubjectAction( + _prevState: SavePushSubjectState, + formData: FormData, +): Promise { + const subject = String(formData.get("subject") ?? "").trim(); + if (!/^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/.test(subject)) { + return { error: "Enter a valid contact address, e.g. mailto:support@example.com.", saved: false, rollout: null }; + } + + const resolved = await savePushSubject(subject); + if (!resolved || !isPushConfigComplete(resolved)) { + // No key pair generated yet — the subject is saved for next time, but there's nothing to roll out. + return { error: null, saved: true, rollout: null }; + } + + const rollout = await applyPushSettingsToAllBusinesses(resolved); + return { error: null, saved: true, rollout }; +} + +export interface ReapplyPushSettingsState { + error: string | null; + rollout: PushRolloutResult[] | null; +} + +export async function reapplyPushSettingsAction( + _prevState: ReapplyPushSettingsState, + _formData: FormData, +): Promise { + const resolved = await resolvePushSettings(); + if (!resolved || !isPushConfigComplete(resolved)) { + return { error: "Push settings aren't fully configured yet — nothing to reapply.", rollout: null }; + } + const rollout = await applyPushSettingsToAllBusinesses(resolved); + return { error: null, rollout }; +} diff --git a/manager/src/app/dashboard/settings/email-settings-form.tsx b/manager/src/app/dashboard/settings/email-settings-form.tsx index c7ab503..9c91f5b 100644 --- a/manager/src/app/dashboard/settings/email-settings-form.tsx +++ b/manager/src/app/dashboard/settings/email-settings-form.tsx @@ -1,6 +1,7 @@ "use client"; -import { useActionState, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useActionState, useEffect, useState } from "react"; import type { EmailEncryptionMode, EmailSettingsView } from "@/lib/global-settings"; import { reapplyEmailSettingsAction, @@ -60,10 +61,22 @@ function StatusBadge({ initial, saveState, testState }: { initial: EmailSettings * clicking "Save"). Controlled state survives that re-render untouched. */ export function EmailSettingsForm({ initial }: { initial: EmailSettingsView }) { + const router = useRouter(); const [saveState, saveAction, isSaving] = useActionState(saveEmailSettingsAction, saveInitialState); const [testState, testAction, isTesting] = useActionState(sendTestEmailAction, testInitialState); const [reapplyState, reapplyAction, isReapplying] = useActionState(reapplyEmailSettingsAction, reapplyInitialState); + // `initial` is a server-fetched snapshot from page load — after a + // successful save, refresh it so fields that reflect *stored* state + // (hasPassword's placeholder text, the Reapply button's disabled state) + // don't keep showing the pre-save snapshot even though the save + // genuinely succeeded (confirmed live: without this, "Reapply to all + // businesses" stayed disabled and the password hint still said "No + // password saved yet." right after a successful first save). + useEffect(() => { + if (saveState.saved) router.refresh(); + }, [saveState, router]); + const [host, setHost] = useState(initial.host); const [port, setPort] = useState(initial.port); const [username, setUsername] = useState(initial.username); diff --git a/manager/src/app/dashboard/settings/page.tsx b/manager/src/app/dashboard/settings/page.tsx index a4714b2..74db5c9 100644 --- a/manager/src/app/dashboard/settings/page.tsx +++ b/manager/src/app/dashboard/settings/page.tsx @@ -1,17 +1,19 @@ import type { Metadata } from "next"; -import { readEmailSettingsView } from "@/lib/global-settings"; +import { readEmailSettingsView, readPushSettingsView } from "@/lib/global-settings"; import { EmailSettingsForm } from "./email-settings-form"; +import { PushSettingsForm } from "./push-settings-form"; export const metadata: Metadata = { title: "Settings" }; /** * Platform-wide settings shared by every business on this installation — * see src/lib/global-settings.ts's own header for the storage design. - * Only "Email" exists today; a later section (SMS, storage, backups, - * branding, locale, monitoring) is a sibling here, not a rewrite. + * "Email" and "Push Notifications" exist today; a later section (SMS, + * storage, backups, branding, locale, monitoring) is a sibling here, not + * a rewrite. */ export default async function SettingsPage() { - const email = await readEmailSettingsView(); + const [email, push] = await Promise.all([readEmailSettingsView(), readPushSettingsView()]); return (
@@ -20,6 +22,7 @@ export default async function SettingsPage() {

Platform-wide configuration shared by every business on this installation.

+ ); } diff --git a/manager/src/app/dashboard/settings/push-settings-form.tsx b/manager/src/app/dashboard/settings/push-settings-form.tsx new file mode 100644 index 0000000..3836225 --- /dev/null +++ b/manager/src/app/dashboard/settings/push-settings-form.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useActionState, useEffect, useState } from "react"; +import type { PushSettingsView } from "@/lib/global-settings"; +import { + generateVapidKeysAction, + reapplyPushSettingsAction, + savePushSubjectAction, + type GenerateVapidKeysState, + type ReapplyPushSettingsState, + type SavePushSubjectState, +} from "./actions"; + +const generateInitialState: GenerateVapidKeysState = { error: null, needsConfirmation: false, generated: false, rollout: null }; +const saveInitialState: SavePushSubjectState = { error: null, saved: false, rollout: null }; +const reapplyInitialState: ReapplyPushSettingsState = { error: null, rollout: null }; + +function StatusBadge({ initial, generateState, saveState }: { initial: PushSettingsView; generateState: GenerateVapidKeysState; saveState: SavePushSubjectState }) { + const isConfigured = + (generateState.generated && generateState.rollout !== null) || + (saveState.saved && saveState.rollout !== null) || + (initial.hasPrivateKey && Boolean(initial.subject)); + return isConfigured ? ( + Configured + ) : ( + Not configured + ); +} + +/** + * No hand-pasted key fields — see global-settings.ts's generateVapidKeys + * for why (a mismatched pair would let browsers subscribe successfully + * while every server-side send silently fails). All inputs are controlled + * (useState), same reasoning as email-settings-form.tsx: this form has + * three separate actions sharing one Subject field, and an uncontrolled + * `defaultValue` input would reset after any one of them resolves. + * + * No `` here — Manager has no shared dialog primitive (no UI kit + * at all, by design; see edit-business-form.tsx's plain-HTML convention). + * The overwrite-protection ask is met with an inline warning panel + * instead: "Generate" is rejected server-side the first time + * (`needsConfirmation: true`) rather than acting immediately, and only a + * second, explicit click on "Yes, Generate New Keys" resubmits with + * `confirmOverwrite=true`. + */ +export function PushSettingsForm({ initial }: { initial: PushSettingsView }) { + const router = useRouter(); + const [generateState, generateAction, isGenerating] = useActionState(generateVapidKeysAction, generateInitialState); + const [saveState, saveAction, isSaving] = useActionState(savePushSubjectAction, saveInitialState); + const [reapplyState, reapplyAction, isReapplying] = useActionState(reapplyPushSettingsAction, reapplyInitialState); + + // See email-settings-form.tsx's identical effect for why: `initial` is + // a server snapshot from page load, and fields like the Public + // Key/Private Key display and the Reapply button's disabled state + // depend on it directly — without refreshing, they'd keep showing + // "Not generated yet" even immediately after a successful Generate. + useEffect(() => { + if (generateState.generated || saveState.saved) router.refresh(); + }, [generateState, saveState, router]); + + const [subject, setSubject] = useState(initial.subject || "mailto:"); + const [confirmOverwrite, setConfirmOverwrite] = useState(false); + + const hasExistingKeys = initial.hasPrivateKey || generateState.generated; + + return ( +
+
+

Push Notifications

+ +
+

+ Shared by every business on this installation — lets any business send browser push notifications without + generating its own key pair. Individual businesses and users still separately control whether they use push + at all; this only controls whether the platform is technically able to. +

+ +
+
+ + +

Not a secret — browsers need this to create a push subscription.

+
+
+ + +

Never shown, logged, or returned to the browser once generated.

+
+
+ + setSubject(e.target.value)} + placeholder="mailto:support@example.com" + className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm" + /> +

+ A contact address push services may use if there's an abuse concern — must start with{" "} + mailto:. +

+
+ + {generateState.error && ( +

+ {generateState.error} +

+ )} + {saveState.error && ( +

+ {saveState.error} +

+ )} + {(generateState.generated || saveState.saved) && !generateState.error && !saveState.error && ( +
+ {(() => { + const rollout = generateState.rollout ?? saveState.rollout; + if (rollout === null) return

Saved as a draft — add a contact address and keys to enable delivery.

; + return ( +

+ Applied to {rollout.filter((r) => r.ok).length} of {rollout.length} business + {rollout.length === 1 ? "" : "es"}. + {rollout.some((r) => !r.ok) && ( + <> Failed: {rollout.filter((r) => !r.ok).map((r) => `${r.slug} (${r.error})`).join(", ")} + )} +

+ ); + })()} +
+ )} + + {generateState.needsConfirmation && ( +
+

A VAPID key pair already exists.

+

+ Every browser push subscription across every business was created using the current public key. + Generating a new pair will make those subscriptions stop receiving pushes — each device will need to + re-enable push notifications afterward (the app already detects and cleans up the broken subscriptions + automatically the next time a push is attempted; nothing needs to be deleted by hand). +

+ +
+ )} + +
+ + + +
+ {reapplyState.error && ( +

+ {reapplyState.error} +

+ )} + {reapplyState.rollout && ( +

+ Reapplied to {reapplyState.rollout.filter((r) => r.ok).length} of {reapplyState.rollout.length} business + {reapplyState.rollout.length === 1 ? "" : "es"}. +

+ )} +
+
+ ); +} diff --git a/manager/src/lib/compose-template.ts b/manager/src/lib/compose-template.ts index a2b0bee..08027bc 100644 --- a/manager/src/lib/compose-template.ts +++ b/manager/src/lib/compose-template.ts @@ -57,6 +57,12 @@ export interface BusinessEnvEmailValues { replyTo: string; } +export interface BusinessEnvPushValues { + publicKey: string; + privateKey: string; + subject: string; +} + export interface BusinessEnvValues { name: string; domain: string; @@ -75,6 +81,8 @@ export interface BusinessEnvValues { * provisioned before global settings exist behaves exactly as before. */ email?: BusinessEnvEmailValues; + /** Global platform VAPID/Web Push settings (see src/lib/global-settings.ts) — same pattern as `email` above. Omitted falls back to today's blank/"console" defaults. */ + push?: BusinessEnvPushValues; } /** Same shape as .env.business.example, filled in with generated/collected values instead of placeholders. */ @@ -100,6 +108,17 @@ EMAIL_FROM_ADDRESS="" EMAIL_FROM_NAME="" EMAIL_REPLY_TO=""`; + const push = values.push; + const pushBlock = push + ? `PUSH_PROVIDER="web-push" +VAPID_PUBLIC_KEY="${push.publicKey}" +VAPID_PRIVATE_KEY="${push.privateKey}" +VAPID_SUBJECT="${push.subject}"` + : `PUSH_PROVIDER="console" +VAPID_PUBLIC_KEY="" +VAPID_PRIVATE_KEY="" +VAPID_SUBJECT="mailto:owner@example.com"`; + return `# Generated by the management UI. See .env.business.example for what # each of these does. # BUSINESS_NAME is Manager-only bookkeeping (shown on the dashboard/edit @@ -140,11 +159,13 @@ GOOGLE_PLACES_API_KEY="" # to hand-edit these for one business only. ${emailBlock} -PUSH_PROVIDER="console" -VAPID_PUBLIC_KEY="" -NEXT_PUBLIC_VAPID_PUBLIC_KEY="" -VAPID_PRIVATE_KEY="" -VAPID_SUBJECT="mailto:owner@example.com" +# Managed centrally — see the Manager's Settings > Push Notifications page. +# NEXT_PUBLIC_VAPID_PUBLIC_KEY intentionally does not exist here — the app +# serves its public key at runtime via GET /api/push/vapid-public-key +# instead (a build-time NEXT_PUBLIC_* var can't work here: every business +# shares one prebuilt image, so it would be baked in identically — and +# blank — for all of them). +${pushBlock} `; } diff --git a/manager/src/lib/global-settings.ts b/manager/src/lib/global-settings.ts index e267f87..425dcc2 100644 --- a/manager/src/lib/global-settings.ts +++ b/manager/src/lib/global-settings.ts @@ -1,6 +1,7 @@ import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; +import webpush from "web-push"; import { stacksRoot } from "./stacks"; /** @@ -15,10 +16,13 @@ import { stacksRoot } from "./stacks"; * restarts/redeploys with zero new volumes — the same guarantee every * business's own `.env` already relies on. * - * Shaped as `{ email: EmailSettings | null, ... }` so a later section - * (SMS, storage, backups, branding, locale, monitoring) is just one more - * top-level key sharing these same read/write/encrypt helpers, not a new - * storage decision each time. + * Shaped as `{ email: EmailSettings | null, push: PushSettings | null, ... }` + * so a later section (SMS, storage, backups, branding, locale, monitoring) + * is just one more top-level key sharing these same read/write/encrypt + * helpers, not a new storage decision each time. Every write function + * spreads `current` before overriding its own key — deliberately, so + * saving one section (e.g. email) can never clobber another (e.g. push) + * that happens to already be stored in the same file. */ export type EmailEncryptionMode = "none" | "starttls" | "tls"; @@ -36,8 +40,17 @@ interface StoredEmailSettings { lastTest: LastTestResult | null; } +/** Persisted shape — the private key field only ever holds the encrypted envelope, never plaintext. */ +interface StoredPushSettings { + publicKey: string; + encryptedPrivateKey: EncryptedPayload | null; + subject: string; + generatedAt: string | null; +} + interface StoredGlobalSettings { email: StoredEmailSettings | null; + push: StoredPushSettings | null; } export interface LastTestResult { @@ -83,6 +96,21 @@ export interface ResolvedEmailSettings { replyTo: string; } +/** Safe-to-render shape — never carries the real private key, only whether one is set. The public key is NOT a secret (browsers need it to create subscriptions) and is shown in full. */ +export interface PushSettingsView { + publicKey: string; + hasPrivateKey: boolean; + subject: string; + generatedAt: string | null; +} + +/** The fully-resolved shape (real plaintext private key included) — used only server-side, for the actual .env push, never rendered or logged. */ +export interface ResolvedPushSettings { + publicKey: string; + privateKey: string; + subject: string; +} + function settingsFilePath(): string { return path.join(stacksRoot(), "manager", "global-settings.json"); } @@ -91,10 +119,10 @@ async function readStoredSettings(): Promise { try { const raw = await readFile(settingsFilePath(), "utf8"); const parsed = JSON.parse(raw) as StoredGlobalSettings; - return { email: parsed.email ?? null }; + return { email: parsed.email ?? null, push: parsed.push ?? null }; } catch { // No file yet (first run) or unreadable — treat as "nothing configured" rather than throwing, so the Settings page can always render. - return { email: null }; + return { email: null, push: null }; } } @@ -144,7 +172,7 @@ function decrypt(payload: EncryptedPayload): string { return plaintext.toString("utf8"); } -// ── Public API ─────────────────────────────────────────────────────────── +// ── Email ──────────────────────────────────────────────────────────────── const DEFAULT_EMAIL_VIEW: EmailSettingsView = { host: "", @@ -226,7 +254,7 @@ export async function saveEmailSettings(input: EmailSettingsInput): Promise { const current = await readStoredSettings(); if (!current.email) return; // nothing to attach the result to - await writeStoredSettings({ email: { ...current.email, lastTest: result } }); + await writeStoredSettings({ ...current, email: { ...current.email, lastTest: result } }); +} + +// ── Push / VAPID ───────────────────────────────────────────────────────── + +const DEFAULT_PUSH_VIEW: PushSettingsView = { + publicKey: "", + hasPrivateKey: false, + subject: "", + generatedAt: null, +}; + +/** Safe for the Settings page to render directly — the private key is never included, only whether one is set. The public key is not a secret. */ +export async function readPushSettingsView(): Promise { + const { push } = await readStoredSettings(); + if (!push) return DEFAULT_PUSH_VIEW; + return { + publicKey: push.publicKey, + hasPrivateKey: push.encryptedPrivateKey !== null, + subject: push.subject, + generatedAt: push.generatedAt, + }; +} + +/** Server-side only — decrypts the real private key. Used for the .env push. Returns null if no key pair has been generated yet. */ +export async function resolvePushSettings(): Promise { + const { push } = await readStoredSettings(); + if (!push || !push.encryptedPrivateKey) return null; + return { + publicKey: push.publicKey, + privateKey: decrypt(push.encryptedPrivateKey), + subject: push.subject, + }; +} + +export function isPushConfigComplete(settings: Pick | null): boolean { + return Boolean(settings && settings.publicKey.trim() && settings.privateKey.trim() && settings.subject.trim()); +} + +export class VapidKeysAlreadyExistError extends Error { + constructor() { + super("A VAPID key pair already exists. Regenerating requires explicit confirmation."); + } +} + +/** + * Generates a brand-new VAPID key pair via the `web-push` package and + * stores it — the *only* way a key pair is ever set (no hand-pasted + * public/private key fields exist in the UI). This avoids a mismatched + * pair ever being stored: a "public key" that doesn't correspond to the + * stored private key would let browsers subscribe successfully while + * every server-side send silently fails signature verification, a + * confusing failure mode with no clear cause. + * + * Refuses to overwrite an existing key pair unless `confirmOverwrite` is + * explicitly true — existing browser subscriptions were created against + * the current public key and will stop being deliverable once it changes + * (see docs on `web-push-provider.ts`'s 401/403 handling for what happens + * to them next), so this must never happen silently. + */ +export async function generateVapidKeys(params: { subject: string; confirmOverwrite?: boolean }): Promise { + const current = await readStoredSettings(); + if (current.push?.encryptedPrivateKey && !params.confirmOverwrite) { + throw new VapidKeysAlreadyExistError(); + } + + const { publicKey, privateKey } = webpush.generateVAPIDKeys(); + const subject = params.subject.trim(); + + const next: StoredPushSettings = { + publicKey, + encryptedPrivateKey: encrypt(privateKey), + subject, + generatedAt: new Date().toISOString(), + }; + await writeStoredSettings({ ...current, push: next }); + + return { publicKey, privateKey, subject }; +} + +/** Updates only the contact/subject email — the key pair itself is untouched (see generateVapidKeys for why keys are generate-only). */ +export async function savePushSubject(subject: string): Promise { + const current = await readStoredSettings(); + if (!current.push) { + // No key pair generated yet — nothing to attach a subject to server-side; the caller should generate keys first. Still record the subject so it's pre-filled once keys are generated. + await writeStoredSettings({ ...current, push: { publicKey: "", encryptedPrivateKey: null, subject: subject.trim(), generatedAt: null } }); + return null; + } + const next: StoredPushSettings = { ...current.push, subject: subject.trim() }; + await writeStoredSettings({ ...current, push: next }); + if (!next.encryptedPrivateKey) return null; + return { publicKey: next.publicKey, privateKey: decrypt(next.encryptedPrivateKey), subject: next.subject }; } diff --git a/manager/src/lib/provisioning.ts b/manager/src/lib/provisioning.ts index f1fae7f..7800f2d 100644 --- a/manager/src/lib/provisioning.ts +++ b/manager/src/lib/provisioning.ts @@ -5,7 +5,7 @@ import mariadb, { type Connection } from "mariadb"; import { generateBusinessEnv, generateCaddySite, generateComposeYaml } from "./compose-template"; import { composeDown, composeUp, reloadCaddy, runMigrations } from "./docker"; import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file"; -import { isEmailConfigComplete, resolveEmailSettings } from "./global-settings"; +import { isEmailConfigComplete, isPushConfigComplete, resolveEmailSettings, resolvePushSettings } from "./global-settings"; import { isValidSlug, listBusinessStacks, stackDir, stackExists, stacksRoot } from "./stacks"; /** Hard safety check on top of isValidSlug (which already constrains the slug itself) — this is the one place that string ends up inside a raw SQL statement, where an identifier can't be parameter-bound the way a value can. */ @@ -163,6 +163,15 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise< } : undefined; + // Same pattern as email above — global VAPID keys (Manager's Settings > + // Push Notifications page) baked in at creation, only when a complete + // key pair actually exists. + const globalPushSettings = await resolvePushSettings(); + const push = + globalPushSettings && isPushConfigComplete(globalPushSettings) + ? { publicKey: globalPushSettings.publicKey, privateKey: globalPushSettings.privateKey, subject: globalPushSettings.subject } + : undefined; + const dir = stackDir(slug); await mkdir(dir, { recursive: true }); await writeFile(path.join(dir, "compose.yaml"), generateComposeYaml(slug), "utf8"); @@ -178,6 +187,7 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise< installationToken, cronSecret, email, + push, }), "utf8", ); diff --git a/manager/src/lib/push-rollout.ts b/manager/src/lib/push-rollout.ts new file mode 100644 index 0000000..5de4592 --- /dev/null +++ b/manager/src/lib/push-rollout.ts @@ -0,0 +1,53 @@ +import path from "node:path"; +import { composeUp } from "./docker"; +import { upsertEnvValues } from "./env-file"; +import type { ResolvedPushSettings } from "./global-settings"; +import { listBusinessStacks, stackDir } from "./stacks"; + +export interface PushRolloutResult { + slug: string; + ok: boolean; + error?: string; +} + +/** + * Pushes the global VAPID key pair into every business's own `.env` (same + * mechanism as email-rollout.ts's applyEmailSettingsToAllBusinesses — the + * surgical, non-destructive `upsertEnvValues` helper, then a + * force-recreate so the change actually takes effect). Unlike email's + * reply-to, every one of these fields is always overwritten: VAPID + * identity is platform-wide infrastructure, never a business preference + * (a business has no legitimate reason to run its own key pair — see + * global-settings.ts's own header). + * + * Deliberately does NOT write NEXT_PUBLIC_VAPID_PUBLIC_KEY — that + * variable is retired (see src/app/api/push/vapid-public-key/route.ts in + * the main app, which now serves the public key at runtime instead of it + * being build-time-inlined). Recreating the container is still required + * for the *server-side* VAPID_* vars to take effect, exactly like email. + * + * Runs sequentially, not in parallel, for the same reason + * email-rollout.ts does — see that file's own header. + */ +export async function applyPushSettingsToAllBusinesses(settings: ResolvedPushSettings): Promise { + const businesses = await listBusinessStacks(); + const results: PushRolloutResult[] = []; + + for (const business of businesses) { + try { + const dir = stackDir(business.slug); + await upsertEnvValues(path.join(dir, ".env"), { + PUSH_PROVIDER: "web-push", + VAPID_PUBLIC_KEY: settings.publicKey, + VAPID_PRIVATE_KEY: settings.privateKey, + VAPID_SUBJECT: settings.subject, + }); + await composeUp(dir, { forceRecreate: true }); + results.push({ slug: business.slug, ok: true }); + } catch (error) { + results.push({ slug: business.slug, ok: false, error: error instanceof Error ? error.message : String(error) }); + } + } + + return results; +} diff --git a/src/app/api/push/vapid-public-key/__tests__/route.test.ts b/src/app/api/push/vapid-public-key/__tests__/route.test.ts new file mode 100644 index 0000000..eaa3503 --- /dev/null +++ b/src/app/api/push/vapid-public-key/__tests__/route.test.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { GET } from "../route"; + +/** + * The one contract that matters for this route: it must NEVER expose + * VAPID_PRIVATE_KEY, under any configuration — this is a public, + * unauthenticated endpoint (see the route's own header for why that's + * intentional for the public key specifically). + */ +describe("GET /api/push/vapid-public-key", () => { + const originalPublicKey = process.env.VAPID_PUBLIC_KEY; + const originalPrivateKey = process.env.VAPID_PRIVATE_KEY; + + beforeEach(() => { + process.env.VAPID_PRIVATE_KEY = "super-secret-private-key-value"; + }); + + afterEach(() => { + if (originalPublicKey === undefined) delete process.env.VAPID_PUBLIC_KEY; + else process.env.VAPID_PUBLIC_KEY = originalPublicKey; + if (originalPrivateKey === undefined) delete process.env.VAPID_PRIVATE_KEY; + else process.env.VAPID_PRIVATE_KEY = originalPrivateKey; + }); + + it("returns the public key when configured", async () => { + process.env.VAPID_PUBLIC_KEY = "a-real-public-key"; + const response = await GET(); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toEqual({ publicKey: "a-real-public-key" }); + }); + + it("returns null when not configured, rather than throwing", async () => { + delete process.env.VAPID_PUBLIC_KEY; + const response = await GET(); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toEqual({ publicKey: null }); + }); + + it("never includes the private key in the response body, in any configuration", async () => { + process.env.VAPID_PUBLIC_KEY = "a-real-public-key"; + const response = await GET(); + const rawText = JSON.stringify(await response.json()); + expect(rawText).not.toContain("super-secret-private-key-value"); + expect(rawText).not.toContain("privateKey"); + }); +}); diff --git a/src/app/api/push/vapid-public-key/route.ts b/src/app/api/push/vapid-public-key/route.ts new file mode 100644 index 0000000..0332abd --- /dev/null +++ b/src/app/api/push/vapid-public-key/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; + +/** + * Serves the VAPID public key at runtime instead of via a build-time + * `NEXT_PUBLIC_*` variable. This matters specifically because every + * business runs the same prebuilt shared image (see + * manager/src/lib/compose-template.ts's generateComposeYaml) — a + * `NEXT_PUBLIC_*` var is inlined into the client bundle at `next build` + * time, so it would be baked in identically (and blank, since it's never + * passed as a build ARG) for every business regardless of that business's + * own `.env`. Reading `VAPID_PUBLIC_KEY` fresh from `process.env` here + * instead means a value the Manager pushes into a business's `.env` (see + * manager/src/lib/push-rollout.ts) reaches the browser as soon as that + * business's container is recreated — no image rebuild involved, matching + * the SMTP settings' own env + container-recreate mechanism exactly. + * + * Public and unauthenticated on purpose: the public key is not a secret — + * browsers require it to create a push subscription at all (see + * push-subscribe-toggle.tsx) — only `VAPID_PRIVATE_KEY` is protected, and + * it is never read by this route. + */ +export async function GET() { + return NextResponse.json({ publicKey: process.env.VAPID_PUBLIC_KEY ?? null }); +} diff --git a/src/features/notifications/components/push-subscribe-toggle.tsx b/src/features/notifications/components/push-subscribe-toggle.tsx index fd85d4d..055f7e7 100644 --- a/src/features/notifications/components/push-subscribe-toggle.tsx +++ b/src/features/notifications/components/push-subscribe-toggle.tsx @@ -1,11 +1,11 @@ "use client"; -import { useEffect, useState, useTransition } from "react"; +import { useEffect, useRef, useState, useTransition } from "react"; import { Button } from "@/components/ui/button"; import { subscribeToPushAction, unsubscribeFromPushAction } from "../actions/push-subscriptions"; import { urlBase64ToUint8Array } from "../lib/vapid"; -type Status = "checking" | "unsupported" | "subscribed" | "not-subscribed" | "denied"; +type Status = "checking" | "unsupported" | "unconfigured" | "subscribed" | "not-subscribed" | "denied"; /** * The one control that ever asks for push permission — deliberately not @@ -19,6 +19,26 @@ function isPushSupported(): boolean { return typeof window !== "undefined" && "serviceWorker" in navigator && "PushManager" in window; } +/** + * Fetched at runtime from the app's own API route rather than read from + * `process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY` — every business shares one + * prebuilt image (see manager/src/lib/compose-template.ts), so a + * `NEXT_PUBLIC_*` var would be inlined at build time identically (and + * blank) for every business regardless of its own `.env`. See + * src/app/api/push/vapid-public-key/route.ts's own header for the full + * reasoning. + */ +async function fetchVapidPublicKey(): Promise { + try { + const response = await fetch("/api/push/vapid-public-key"); + if (!response.ok) return null; + const data = (await response.json()) as { publicKey: string | null }; + return data.publicKey; + } catch { + return null; + } +} + export function PushSubscribeToggle() { // Always starts as "checking" (renders nothing) on both server and // client — `isPushSupported`/`Notification.permission` are @@ -29,6 +49,11 @@ export function PushSubscribeToggle() { const [status, setStatus] = useState("checking"); const [error, setError] = useState(null); const [isPending, startTransition] = useTransition(); + // Cached from the one fetch in the effect below, so `subscribe()` + // doesn't need to re-fetch it (and so the "not configured" state can be + // shown up front, before the user ever clicks Enable — no wasted + // permission prompt for a business that has no key configured). + const vapidPublicKeyRef = useRef(null); useEffect(() => { if (status !== "checking") return; @@ -37,6 +62,11 @@ export function PushSubscribeToggle() { Promise.resolve() .then(async () => { if (!isPushSupported()) return "unsupported" as const; + + const vapidPublicKey = await fetchVapidPublicKey(); + vapidPublicKeyRef.current = vapidPublicKey; + if (!vapidPublicKey) return "unconfigured" as const; + if (Notification.permission === "denied") return "denied" as const; const reg = await navigator.serviceWorker.getRegistration(); const sub = await reg?.pushManager.getSubscription(); @@ -58,9 +88,9 @@ export function PushSubscribeToggle() { setError(null); startTransition(async () => { try { - const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY; + const vapidPublicKey = vapidPublicKeyRef.current ?? (await fetchVapidPublicKey()); if (!vapidPublicKey) { - setError("Push notifications aren't configured for this business yet."); + setStatus("unconfigured"); return; } @@ -119,6 +149,10 @@ export function PushSubscribeToggle() { return

Push notifications aren't supported in this browser.

; } + if (status === "unconfigured") { + return

Push notifications aren't configured for this business yet.

; + } + if (status === "denied") { return (

diff --git a/src/services/notifications/providers/__tests__/web-push-provider.test.ts b/src/services/notifications/providers/__tests__/web-push-provider.test.ts index a368736..d2a019b 100644 --- a/src/services/notifications/providers/__tests__/web-push-provider.test.ts +++ b/src/services/notifications/providers/__tests__/web-push-provider.test.ts @@ -48,6 +48,19 @@ describe("WebPushProvider", () => { expect((result as { subscriptionInvalid?: boolean }).subscriptionInvalid).toBe(true); }); + it("flags a 401 (invalid VAPID signature — e.g. a rotated key) as a permanently invalid subscription, not retryable", async () => { + sendNotification.mockRejectedValueOnce({ statusCode: 401, message: "Unauthorized" }); + const result = await provider.sendPush(params); + expect(result).toEqual({ status: "failed", retryable: false, subscriptionInvalid: true, reason: "Subscription is no longer valid." }); + }); + + it("flags a 403 the same way as a 401", async () => { + sendNotification.mockRejectedValueOnce({ statusCode: 403, message: "Forbidden" }); + const result = await provider.sendPush(params); + expect(result.status).toBe("failed"); + expect((result as { subscriptionInvalid?: boolean }).subscriptionInvalid).toBe(true); + }); + it("treats a 5xx push-service error as retryable, not a subscription problem", async () => { sendNotification.mockRejectedValueOnce({ statusCode: 503, message: "Service unavailable" }); const result = await provider.sendPush(params); diff --git a/src/services/notifications/providers/web-push-provider.ts b/src/services/notifications/providers/web-push-provider.ts index 8ac1c78..d308067 100644 --- a/src/services/notifications/providers/web-push-provider.ts +++ b/src/services/notifications/providers/web-push-provider.ts @@ -50,7 +50,26 @@ export class WebPushProvider implements PushProvider { // 404/410: the push service itself says this subscription no longer // exists (browser data cleared, permission revoked, uninstalled) — // never retryable, and the caller should revoke the row. - if (webPushError.statusCode === 404 || webPushError.statusCode === 410) { + // + // 401/403: a VAPID signature the push service rejects as invalid — + // in this app that can only mean the subscription was created + // against a public key that no longer matches the currently + // configured private key (i.e. the platform's VAPID keys were + // regenerated after this subscription was made — see the Manager's + // Settings > Push Notifications "Generate VAPID Keys" action). This + // subscription can never succeed again until the browser + // re-subscribes with the new public key, so it's just as permanent + // as a 404/410 — treating it the same way is what actually makes + // "existing subscriptions need to subscribe again" a self-healing + // outcome (the stale row gets revoked automatically on first send + // attempt, rather than failing silently forever) instead of a + // manual cleanup job. + if ( + webPushError.statusCode === 404 || + webPushError.statusCode === 410 || + webPushError.statusCode === 401 || + webPushError.statusCode === 403 + ) { return { status: "failed", retryable: false, subscriptionInvalid: true, reason: "Subscription is no longer valid." }; } if (typeof webPushError.statusCode === "number" && webPushError.statusCode >= 500) {