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

+26 -5
View File
@@ -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}
`;
}
+128 -9
View File
@@ -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<StoredGlobalSettings> {
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<Reso
replyTo: input.replyTo.trim(),
lastTest: current.email?.lastTest ?? null,
};
await writeStoredSettings({ email: next });
await writeStoredSettings({ ...current, email: next });
return {
host: next.host,
@@ -243,5 +271,96 @@ export async function saveEmailSettings(input: EmailSettingsInput): Promise<Reso
export async function recordLastTestResult(result: LastTestResult): Promise<void> {
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<PushSettingsView> {
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<ResolvedPushSettings | null> {
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<ResolvedPushSettings, "publicKey" | "privateKey" | "subject"> | 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<ResolvedPushSettings> {
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<ResolvedPushSettings | null> {
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 };
}
+11 -1
View File
@@ -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",
);
+53
View File
@@ -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<PushRolloutResult[]> {
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;
}