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.
NextChair Manager
Standalone platform-operator tool for provisioning and managing NextChair
business deployments on the shared multi-business VPS setup (see
../stacks/). Not part of the main app — a genuinely separate Next.js
app, own package.json, own build, own container.
Why it's a separate app
An earlier attempt at this exact feature was built as routes inside the
main app (src/app/manager/...). It was reverted after repeated,
never-fully-resolved bugs: the main app's src/proxy.ts, root layout,
root page, and global SessionProvider all statically import Prisma /
Auth.js / business branding — code a Manager (which has no business
database of its own, by definition) kept transitively pulling in, causing
crashes in "Manager mode."
This app avoids that class of bug structurally rather than by careful
avoidance: it never imports anything from the main app's src/ at all.
A few small pieces (bcrypt convention, the signed-cookie primitive) were
copied and adapted, not imported — see src/lib/auth.ts and
src/lib/session.ts.
What it does
- Lists provisioned businesses and their running/stopped state.
- Add Business: collects a name/slug/domain, then generates secrets,
creates that business's database + user on the shared MySQL server,
writes its
stacks/<slug>/compose.yaml+.env+ Caddy site file, reloads Caddy, starts the container, and runs its migrations. Seesrc/lib/provisioning.tsfor the exact sequence. - Per business: start / stop / restart / update (recreate the container against the current shared image tag — not a source rebuild) / view recent logs.
- Does not duplicate the
/installwizard — after provisioning, the operator is linked tohttps://<domain>/installto finish that business's own setup.
How auth works
A single dedicated platform-operator credential (MANAGER_EMAIL /
MANAGER_PASSWORD_HASH, bcrypt cost 12) — not tied to any one business's
own Super Administrator, since this tool spans every business's separate
database and has no "primary" one of its own. A signed (HMAC, not
encrypted) cookie holds the session; see src/lib/session.ts.
The Docker-socket trust boundary
This container has the host's Docker socket mounted (docker.sock) so it
can run docker compose/docker exec against the host's dockerd — see
src/lib/docker.ts. Root inside this container plus that socket
effectively means root on the host. Consequences:
- It must stay behind the login above at all times — never expose it
without that gate, and don't reuse
MANAGER_PASSWORD_HASHanywhere else. STACKS_DIRmust be bind-mounted at the identical absolute path on both host and container (see.env.example) — adocker compose -f <path>command run from inside this container is executed by the host's dockerd, so the path only means the same file on both sides if the mount doesn't change it. This is the same constraint Dockge itself has.
Setup
cp manager/.env.example stacks/manager/.env # fill in real values
docker compose -f stacks/manager/compose.yaml --env-file stacks/manager/.env up -d --build
Requires stacks/infra/compose.yaml already running (shared MySQL,
Caddy, the next-chair-edge network). Add a Caddy site for this app the
same way as any business (see stacks/infra/sites/.gitkeep), pointed at
next-chair-manager:3000.
Local development
cd manager
npm install
cp .env.example .env.local
npm run dev
Requires network access to a real MySQL server and docker/docker compose on the machine you run it from for the provisioning/stack
actions to actually work — the dashboard's read-only parts (listing
stacks) will also just show nothing if STACKS_DIR doesn't point at a
real directory yet.