From 106f89a6da20359389ae26e6e25d5bf93b13aa9f Mon Sep 17 00:00:00 2001 From: Stefan Lazic Date: Sun, 6 Sep 2026 12:17:10 +0100 Subject: [PATCH] VAPID Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Summary of changes The request assumed a push-notification subsystem needed to be built — it already existed in full: the PushSubscription model, subscribe/unsubscribe service and Server Actions, the PushSubscribeToggle opt-in UI, WebPushProvider, and public/sw.js's push/notificationclick handlers were all already implemented and tested. The one real gap: NEXT_PUBLIC_VAPID_PUBLIC_KEY is a Next.js build-time-inlined variable, but every business runs the same prebuilt shared image — so on the actual deployment architecture, every business's browser bundle has always shipped with this key baked in empty, regardless of that business's .env. This was a genuine pre-existing bug, and centralizing VAPID globally is the fix for it, not just a tidiness improvement. Added a Push Notifications section to the Manager's Settings page, mirroring the SMTP architecture exactly: same JSON storage, same MANAGER_SETTINGS_ENCRYPTION_KEY encryption, same rollout mechanism. Keys are generate-only (web-push.generateVAPIDKeys()) — no hand-pasted key fields, avoiding a mismatched pair that would silently break every push send. Regeneration requires explicit confirmation with a clear warning about the impact, shown live and verified in-browser. Replaced the broken NEXT_PUBLIC_VAPID_PUBLIC_KEY mechanism with a new runtime API route, GET /api/push/vapid-public-key — this is what actually makes centralized VAPID config reach the browser, verified live end-to-end. Fixed a related bug in web-push-provider.ts: a 401/403 (VAPID signature mismatch — exactly what happens to every subscription after a key rotation) wasn't being treated as an invalid subscription, so rotated-out subscriptions would have failed forever without ever being cleaned up. Two additional real bugs found and fixed during live verification (not just written correctly on paper): the settings forms weren't refreshing their server-fetched data after a successful save, so the Public/Private Key fields and the "Reapply" button stayed stale-looking immediately after a successful Generate. No Prisma/schema changes — the existing model already satisfied every field in the request. 2. Architecture used for sharing VAPID configuration Identical to SMTP: Manager is the one place a human ever generates the key pair; it's stored in stacks/manager/global-settings.json, then pushed into every business's .env (VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT, PUSH_PROVIDER="web-push") via the same upsertEnvValues + composeUp(dir, {forceRecreate:true}) mechanism, verified live to actually rewrite a scratch business's .env and recreate its container. New businesses get it baked in at provisioning time automatically. 3. How and where the VAPID private key is stored AES-256-GCM encrypted (same key and mechanism as the SMTP password) inside stacks/manager/global-settings.json. Verified directly: the file contains an {iv, authTag, ciphertext} envelope, never plaintext. Never returned to the Manager UI after generation — only a boolean hasPrivateKey. 4. Prisma/database changes None. The existing PushSubscription model already covers every field requested. 5. Service worker/PWA changes None to the service worker itself — public/sw.js's push/notificationclick handlers already worked correctly. The only client change is push-subscribe-toggle.tsx, which now fetches the public key from the new API route instead of a dead build-time constant, and gained a new "unconfigured" state shown up front (before a wasted permission prompt) when push genuinely isn't configured. 6. How tenant push subscriptions are stored Unchanged, and already correct: each business has its own separate database (confirmed throughout this session's work), so tenant isolation is enforced at the infrastructure level, not just a businessId column — there's no query path from one business's container into another's subscriptions at all. 7. How multiple devices are handled Unchanged — already correct and already tested (endpoint unique, upsert-on-resubscribe, per-user userId/customerId fan-out in reminder-worker.ts). 8. What happens when a subscription becomes invalid Unchanged for 404/410. Newly fixed: 401/403 (VAPID mismatch, e.g. after a key rotation) is now also treated as subscriptionInvalid — the stale subscription gets revoked automatically on the next delivery attempt rather than failing silently forever. 9. What happens if VAPID keys are regenerated Documented in the Manager UI's own confirmation warning (verified live): every existing browser subscription across every business stops receiving pushes, and each device needs to click "Enable Push Notifications" again — but nothing needs to be manually cleaned up, since the app now detects and revokes the broken subscriptions automatically (see #8). 10. Files changed New: manager/src/lib/push-rollout.ts, manager/src/app/dashboard/settings/push-settings-form.tsx, src/app/api/push/vapid-public-key/route.ts + its test. Modified: manager/src/lib/global-settings.ts (push section + a fix ensuring email/push sections never clobber each other), manager/src/lib/provisioning.ts, manager/src/lib/compose-template.ts, manager/src/app/dashboard/settings/{actions.ts,page.tsx,email-settings-form.tsx} (the last one gained the same router.refresh() fix), push-subscribe-toggle.tsx, web-push-provider.ts + its test, .env.example, .env.business.example, manager/package.json/package-lock.json (added web-push). 11. New environment variables None new. MANAGER_SETTINGS_ENCRYPTION_KEY already exists from the SMTP work and is reused. NEXT_PUBLIC_VAPID_PUBLIC_KEY is removed (dead — superseded by the API route). 12. VPS deployment/update steps Rebuild/redeploy both images as usual (no build-arg needed, unlike a naive fix would have required). Open Manager → Settings → Push Notifications, set a contact email, click "Generate VAPID Keys" — this automatically rolls out to every existing business. Nothing else needs to run by hand. 13. Manual testing steps EMAIL_PROVIDER=console PUSH_PROVIDER=console npm run dev Generate keys in Manager, confirm the overwrite-warning on a second attempt, confirm a business's .env gains the 4 keys. Hit curl localhost:PORT/api/push/vapid-public-key — confirmed live to return exactly the configured public key. Sign in to a business and visit Settings → Notifications — confirmed live the toggle correctly reaches the real permission-check step (proving the key delivery works) rather than showing "not configured." The global VAPID / Web Push feature is already fully designed, implemented, and verified — all three research agents finished, I synthesized their findings, wrote the plan, got it approved, built it, found and fixed three real bugs along the way (the broken build-time public-key delivery, the missing 401/403 invalidation handling, and the stale-form-data-after-save issue in both settings forms), and delivered the full report above. That work is done. --- .env.business.example | 9 +- .env.example | 12 +- manager/package-lock.json | 153 +++++++++++- manager/package.json | 4 +- manager/src/app/dashboard/settings/actions.ts | 97 ++++++++ .../settings/email-settings-form.tsx | 15 +- manager/src/app/dashboard/settings/page.tsx | 11 +- .../dashboard/settings/push-settings-form.tsx | 222 ++++++++++++++++++ manager/src/lib/compose-template.ts | 31 ++- manager/src/lib/global-settings.ts | 137 ++++++++++- manager/src/lib/provisioning.ts | 12 +- manager/src/lib/push-rollout.ts | 53 +++++ .../vapid-public-key/__tests__/route.test.ts | 48 ++++ src/app/api/push/vapid-public-key/route.ts | 24 ++ .../components/push-subscribe-toggle.tsx | 42 +++- .../__tests__/web-push-provider.test.ts | 13 + .../providers/web-push-provider.ts | 21 +- 17 files changed, 868 insertions(+), 36 deletions(-) create mode 100644 manager/src/app/dashboard/settings/push-settings-form.tsx create mode 100644 manager/src/lib/push-rollout.ts create mode 100644 src/app/api/push/vapid-public-key/__tests__/route.test.ts create mode 100644 src/app/api/push/vapid-public-key/route.ts 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) {