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.
116 lines
5.7 KiB
Bash
116 lines
5.7 KiB
Bash
# One business's config on the shared multi-business VPS setup — see
|
|
# stacks/infra/compose.yaml for the one-time setup this depends on
|
|
# (creating this business's database on the shared MySQL server) and
|
|
# stacks/<slug>/compose.yaml for how this file is used. Copy this to
|
|
# `stacks/<slug>/.env` (e.g. `stacks/blake-heritage/.env` — kept out of
|
|
# git, see .gitignore) per business and fill in real values. Nothing here
|
|
# is a real credential. Normally the management UI (manager/) generates
|
|
# this file for you — this template is for the by-hand setup path.
|
|
#
|
|
# The app starts and runs a complete first-install flow with ONLY the
|
|
# "Required for basic startup" section below filled in — every other
|
|
# integration (email, push, Google sign-in, Google Calendar) shows a
|
|
# disabled state rather than crashing when its variables are left blank.
|
|
|
|
# ── Required for basic startup ─────────────────────────────────────────
|
|
# Manager-only bookkeeping (shown on the dashboard/edit screen, editable
|
|
# there) — the app itself never reads this; its own business name lives
|
|
# in the database, set via /install.
|
|
BUSINESS_NAME="Blake Heritage Barbershop"
|
|
# The public domain this business is served on — must match the .caddy
|
|
# site file's own domain (see stacks/infra/sites/.gitkeep) and that
|
|
# domain's real DNS (pointed at the VPS) before Caddy can issue a
|
|
# certificate for it.
|
|
DOMAIN="blake-heritage.example.com"
|
|
|
|
# The app reaches MySQL via the shared infra's service hostname
|
|
# `next-chair-mysql`, never `localhost` — see stacks/infra/compose.yaml.
|
|
# DATABASE_NAME/USER/PASSWORD must match exactly what you created for
|
|
# this business on the shared server (see stacks/infra/compose.yaml's
|
|
# "CREATE DATABASE ..." step) — this file doesn't create them, it only
|
|
# tells the app what to connect as.
|
|
DATABASE_HOST="next-chair-mysql"
|
|
DATABASE_PORT="3306"
|
|
DATABASE_NAME="blake_heritage"
|
|
DATABASE_USER="blake_heritage"
|
|
DATABASE_PASSWORD="replace-me"
|
|
# Generate with: npx auth secret
|
|
AUTH_SECRET="replace-me-with-a-32-byte-random-string"
|
|
# Auth.js needs to trust the host it's served from when not on localhost:3000.
|
|
AUTH_TRUST_HOST="true"
|
|
NEXT_PUBLIC_APP_URL="https://blake-heritage.example.com"
|
|
# One-time setup token the installation wizard (/install) requires before
|
|
# it will create the first business/administrator — generate with:
|
|
# openssl rand -hex 32
|
|
INSTALLATION_TOKEN="replace-me"
|
|
# Shared secret an external scheduler presents to
|
|
# /api/cron/process-notifications — generate with: openssl rand -hex 32
|
|
CRON_SECRET="replace-me"
|
|
|
|
# ── Super Administrator (optional — see scripts/ensure-super-admin.ts) ──
|
|
# Platform-level account with every Owner permission for THIS business.
|
|
# Not run automatically by this compose file (that's a Docker-entrypoint-
|
|
# only behaviour, see docker/entrypoint.sh) — run it manually, once:
|
|
# docker exec -it next-chair-blake-heritage npx tsx scripts/ensure-super-admin.ts
|
|
# Leave SUPER_ADMIN_EMAIL blank to skip entirely and use the /install
|
|
# wizard's own administrator account instead.
|
|
SUPER_ADMIN_EMAIL=""
|
|
SUPER_ADMIN_PASSWORD=""
|
|
SUPER_ADMIN_FIRST_NAME=""
|
|
SUPER_ADMIN_LAST_NAME=""
|
|
|
|
# ── Google sign-in (optional — "Continue with Google" on Sign In) ─────
|
|
AUTH_GOOGLE_ID=""
|
|
AUTH_GOOGLE_SECRET=""
|
|
|
|
# ── Google Calendar sync (optional — staff-level, see /staff/settings/calendar) ─
|
|
# From https://console.cloud.google.com/apis/credentials — a separate
|
|
# OAuth client from AUTH_GOOGLE_ID/SECRET above (different scopes).
|
|
GOOGLE_CALENDAR_CLIENT_ID=""
|
|
GOOGLE_CALENDAR_CLIENT_SECRET=""
|
|
GOOGLE_CALENDAR_REDIRECT_URI="https://blake-heritage.example.com/api/staff/calendar/google/callback"
|
|
# Encrypts stored refresh tokens at rest — generate with: openssl rand -hex 32
|
|
CALENDAR_TOKEN_ENCRYPTION_KEY=""
|
|
|
|
# ── Media Manager (optional — see src/lib/media/media-storage.ts) ──────
|
|
# Max upload size in bytes for the Media Manager. Defaults to 5,000,000
|
|
# (5MB) if unset.
|
|
MEDIA_MAX_UPLOAD_BYTES="5000000"
|
|
|
|
# ── Google Reviews (optional — see /owner/settings/reviews) ────────────
|
|
# From https://console.cloud.google.com/apis/credentials — needs "Places
|
|
# API (New)" enabled on that project (a different product from the older
|
|
# "Places API" — enabling the wrong one returns REQUEST_DENIED). Leave
|
|
# blank to keep review sync disabled entirely (Home shows clearly-labelled
|
|
# demo reviews instead).
|
|
GOOGLE_PLACES_API_KEY=""
|
|
|
|
# ── Email (optional — defaults to logging to the console instead of sending) ─
|
|
# Managed centrally by the Manager's Settings > Email 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.
|
|
EMAIL_PROVIDER="console"
|
|
EMAIL_SMTP_HOST="smtp-relay.brevo.com"
|
|
EMAIL_SMTP_PORT="587"
|
|
EMAIL_SMTP_USERNAME=""
|
|
EMAIL_SMTP_PASSWORD=""
|
|
EMAIL_SMTP_ENCRYPTION="starttls"
|
|
EMAIL_FROM_ADDRESS=""
|
|
EMAIL_FROM_NAME=""
|
|
EMAIL_REPLY_TO=""
|
|
|
|
# ── Web Push (optional — defaults to logging to the console instead of sending) ─
|
|
# 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=""
|
|
VAPID_PRIVATE_KEY=""
|
|
VAPID_SUBJECT="mailto:owner@example.com"
|