VAPID Settings
CI / ci (push) Canceled after 0s

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.
This commit is contained in:
Stefan Lazic committed 2026-09-06 12:17:10 +01:00
1 parent 2d4f9c5bf0
commit 106f89a6da
17 files changed
+868 -36

No files matched your search

@@ -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");
});
});
@@ -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 });
}
@@ -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<string | null> {
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<Status>("checking");
const [error, setError] = useState<string | null>(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<string | null>(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 <p className="text-muted text-sm">Push notifications aren&apos;t supported in this browser.</p>;
}
if (status === "unconfigured") {
return <p className="text-muted text-sm">Push notifications aren&apos;t configured for this business yet.</p>;
}
if (status === "denied") {
return (
<p className="text-muted text-sm">
@@ -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);
@@ -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) {