mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
merge(admin): generate VAPID keys in-app and self-hoster backup/env docs
This commit is contained in:
commit
a8ff52e998
12 files changed
+533
-22
No files matched your search
+23
-1
@@ -74,13 +74,35 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# OURA_REDIRECT_URI=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Web Push -- VAPID keys (optional; the admin panel is the easier path)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Web Push needs NO Apple account and no paid service -- just a VAPID keypair.
|
||||
# The EASIEST path is the admin panel: /admin -> Web Push VAPID -> "Generate
|
||||
# keys" mints and stores the pair for you (private key encrypted at rest). Use
|
||||
# these env vars only if you prefer env-config over the DB. The loader reads
|
||||
# DB first, then env (src/lib/notifications/vapid-config.ts). NOTE: these are
|
||||
# NOT in docker-compose.yml's `environment:` whitelist -- if you go the env
|
||||
# route under compose, add them there too or the values never reach the
|
||||
# container (see docs/self-hosting/getting-started.md Troubleshooting).
|
||||
# VAPID_PUBLIC_KEY=""
|
||||
# VAPID_PRIVATE_KEY=""
|
||||
# VAPID_SUBJECT="mailto:you@example.com"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# APNs -- iOS push (optional, all-or-none)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Only the native iOS app uses APNs, and it needs a paid Apple Developer
|
||||
# Program membership. Web Push (above) needs NO Apple account -- it covers
|
||||
# every browser and the installed PWA on iPhone. Leave this whole block unset
|
||||
# unless you ship the native app. Key-source precedence when more than one is
|
||||
# set: APNS_KEY_B64 > APNS_KEY > APNS_KEY_FILE.
|
||||
# APNS_KEY_ID=""
|
||||
# APNS_TEAM_ID=""
|
||||
# APNS_BUNDLE_ID=""
|
||||
# APNS_KEY="" # OR APNS_KEY_FILE -- either one satisfies the manifest
|
||||
# APNS_KEY_B64="" # PEM body base64-encoded -- recommended (escapes newlines)
|
||||
# APNS_KEY="" # OR APNS_KEY (raw PEM) OR APNS_KEY_FILE -- one satisfies the manifest
|
||||
# APNS_KEY_FILE=""
|
||||
|
||||
|
||||
|
||||
@@ -175,3 +175,51 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build
|
||||
- **OAuth callbacks loop back to localhost.** Confirm `APP_URL` and
|
||||
`NEXT_PUBLIC_APP_URL` both point at the public hostname, not at
|
||||
`localhost`, and restart `app` after the change.
|
||||
- **An env var in `.env` seems ignored / a feature stays "off".** The
|
||||
compose `environment:` block is a **whitelist** — a variable set in
|
||||
`.env` only reaches the container if `docker-compose.yml` lists it
|
||||
under `environment:`. Vars not listed never propagate, even with
|
||||
`${VAR}` substitution at compose-up. If a setting looks configured but
|
||||
is silently dead, check the var is in that block (add it if you run a
|
||||
custom compose); the supported alternative for most secrets (VAPID,
|
||||
GitHub token, GlitchTip DSN) is the **admin panel**, which stores them
|
||||
in the database and sidesteps the whitelist entirely.
|
||||
- **A new image doesn't pick up / `:latest` looks stale.** `docker-compose.yml`
|
||||
sets `pull_policy: always`, which is load-bearing: without it Docker
|
||||
re-uses the cached `:latest` digest and silently skips the registry
|
||||
round-trip on `compose up`. If you pin a custom compose, keep
|
||||
`pull_policy: always` on the `app` service, and verify the running
|
||||
build with `GET /api/version` (it returns `version` + `buildSha`).
|
||||
|
||||
## Backup and restore
|
||||
|
||||
Your database is the only stateful piece — back it up, and back up the
|
||||
key that decrypts it.
|
||||
|
||||
**Local snapshot (the common single-box case).** Dump the `db` service
|
||||
with `pg_dump`:
|
||||
|
||||
```bash
|
||||
# The bundled db service uses user `healthlog` and database `healthlog`.
|
||||
docker compose exec -T db pg_dump -U healthlog healthlog \
|
||||
| gzip > healthlog-$(date +%F).sql.gz
|
||||
```
|
||||
|
||||
Restore into a fresh database the same way:
|
||||
|
||||
```bash
|
||||
gunzip -c healthlog-2026-01-01.sql.gz \
|
||||
| docker compose exec -T db psql -U healthlog healthlog
|
||||
```
|
||||
|
||||
> **Back up your `ENCRYPTION_KEYS` / `ENCRYPTION_KEY` alongside the dump.**
|
||||
> HealthLog encrypts sensitive columns at rest (AES-256-GCM). A database
|
||||
> dump **without the encryption key is unrecoverable** — the encrypted
|
||||
> data cannot be read back. Store the key (and any retired keys still in
|
||||
> the rotation map) somewhere separate from the dump but equally durable.
|
||||
> Losing the key means losing the encrypted data, full stop.
|
||||
|
||||
For off-host encrypted backups to S3/R2/B2 see
|
||||
[`docs/ops/backup-restore.md`](../ops/backup-restore.md); for rotating the
|
||||
encryption key safely (keep the old key until zero rows remain on it) see
|
||||
[`docs/ops/encryption-key-rotation.md`](../ops/encryption-key-rotation.md).
|
||||
@@ -34,35 +34,46 @@ mobile — even when the HealthLog tab is closed, as long as the browser
|
||||
or installed PWA is alive. It is the broadest-reach channel and the one
|
||||
to set up first.
|
||||
|
||||
### 1. Generate a VAPID keypair
|
||||
### 1. Generate and store a VAPID keypair
|
||||
|
||||
VAPID is the signing scheme that lets a push service trust your server.
|
||||
Generate a keypair once with the bundled `web-push` CLI:
|
||||
You also choose a **subject** — a `mailto:` address the push service can
|
||||
contact about your sender, e.g. `mailto:you@example.com`.
|
||||
|
||||
The server loads VAPID config from the database first and falls back to
|
||||
environment variables (`src/lib/notifications/vapid-config.ts`). The admin
|
||||
panel is the easiest path because it survives without touching `.env`.
|
||||
|
||||
**Admin panel — Generate keys (easiest).** Sign in as the admin user,
|
||||
open `/admin`, and find the **Web Push VAPID** card
|
||||
(`src/components/admin/web-push-vapid-section.tsx`). Click **Generate
|
||||
keys**: the server mints a fresh keypair, stores the private key encrypted
|
||||
at rest with your `ENCRYPTION_KEY`, seeds a placeholder subject, and fills
|
||||
in the public key. Edit the subject to your real `mailto:` address and
|
||||
save. The card shows a "configured" badge once all three fields are set —
|
||||
no shell, no copy-paste.
|
||||
|
||||
> If a keypair already exists, **Generate keys** asks you to confirm before
|
||||
> replacing it. Regenerating invalidates every existing browser
|
||||
> subscription, so each device has to re-subscribe afterwards. Only
|
||||
> regenerate when you mean to.
|
||||
|
||||
**Admin panel — paste an existing pair.** If you already have a keypair
|
||||
(e.g. copied from another deployment), paste the public key, the private
|
||||
key, and the subject into the same card and save instead of generating.
|
||||
|
||||
**CLI — generate a pair yourself.** You can also mint one with the bundled
|
||||
`web-push` CLI and paste the result into the card or your `.env`:
|
||||
|
||||
```bash
|
||||
npx web-push generate-vapid-keys
|
||||
```
|
||||
|
||||
That prints a **public key** and a **private key** (both Base64URL). You
|
||||
also choose a **subject** — a `mailto:` address the push service can
|
||||
contact about your sender, e.g. `mailto:you@example.com`.
|
||||
That prints a **public key** and a **private key** (both Base64URL).
|
||||
|
||||
### 2. Store the keys
|
||||
### 2. Store the keys via environment variables (alternative)
|
||||
|
||||
The server loads VAPID config from the database first and falls back to
|
||||
environment variables (`src/lib/notifications/vapid-config.ts`). Either
|
||||
path works; the admin panel is the easier one because it survives without
|
||||
touching `.env`.
|
||||
|
||||
**Admin panel (preferred).** Sign in as the admin user, open
|
||||
`/admin`, and find the **Web Push VAPID** card
|
||||
(`src/components/admin/web-push-vapid-section.tsx`). Paste the public key,
|
||||
the private key, and the subject, then save. The private key is encrypted
|
||||
at rest with your `ENCRYPTION_KEY`, exactly like the other secrets in the
|
||||
admin panel. The card shows a "configured" badge once all three fields
|
||||
are set.
|
||||
|
||||
**Environment variables (alternative).** If you would rather keep
|
||||
If you would rather keep
|
||||
secrets out of the database, set them in `.env` / the compose
|
||||
`environment:` block instead:
|
||||
|
||||
@@ -179,6 +190,14 @@ Two gotchas worth knowing before you blame a stale token:
|
||||
`BadEnvironmentKeyInToken`. Confirm the key shows **Sandbox &
|
||||
Production**; a wrongly scoped key cannot be reconfigured — re-issue it.
|
||||
|
||||
> **`clientManaged` is iOS-app-only.** The native iOS app can opt to run
|
||||
> its own local medication reminders and ask the server to stop sending
|
||||
> the duplicate `MEDICATION_REMINDER` push (the `clientManaged` flag on
|
||||
> `PATCH /api/auth/me/notification-prefs`). This is a native-app contract:
|
||||
> a web/PWA-only self-hoster never needs it and there is no toggle for it
|
||||
> in the web UI. If you only run the PWA, ignore it entirely — your
|
||||
> medication reminders come from the server cron over the channels above.
|
||||
|
||||
## Which channel should I pick?
|
||||
|
||||
| Your setup | Recommended channel |
|
||||
|
||||
@@ -4896,6 +4896,11 @@
|
||||
"webPushVapidPrivateKeyPlaceholder": "Optional: neuen Private Key eintragen",
|
||||
"webPushVapidSubject": "VAPID Subject",
|
||||
"webPushVapidSubjectPlaceholder": "mailto:deine-adresse@example.com",
|
||||
"webPushVapidGenerate": "Schlüssel erzeugen",
|
||||
"webPushVapidGenerating": "Wird erzeugt…",
|
||||
"webPushVapidGenerated": "VAPID-Schlüssel erzeugt. Betreff speichern, um abzuschließen.",
|
||||
"webPushVapidGenerateFailed": "VAPID-Schlüssel konnten nicht erzeugt werden.",
|
||||
"webPushVapidGenerateConfirm": "VAPID-Schlüssel sind bereits konfiguriert. Ein neues Schlüsselpaar macht alle Browser-Push-Abos ungültig — jedes Gerät muss sich neu anmelden. Fortfahren?",
|
||||
"configured": "Konfiguriert",
|
||||
"monitoringTestSuccess": "Testevent wurde gesendet",
|
||||
"monitoringTestFailed": "Monitoring-Test fehlgeschlagen",
|
||||
|
||||
@@ -4896,6 +4896,11 @@
|
||||
"webPushVapidPrivateKeyPlaceholder": "Optional: enter a new private key",
|
||||
"webPushVapidSubject": "VAPID subject",
|
||||
"webPushVapidSubjectPlaceholder": "mailto:you@example.com",
|
||||
"webPushVapidGenerate": "Generate keys",
|
||||
"webPushVapidGenerating": "Generating…",
|
||||
"webPushVapidGenerated": "VAPID keys generated. Save the subject to finish.",
|
||||
"webPushVapidGenerateFailed": "Could not generate VAPID keys.",
|
||||
"webPushVapidGenerateConfirm": "VAPID keys are already configured. Generating a new pair invalidates every browser push subscription — each device must re-subscribe. Continue?",
|
||||
"configured": "Configured",
|
||||
"monitoringTestSuccess": "Test event sent",
|
||||
"monitoringTestFailed": "Monitoring test failed",
|
||||
|
||||
@@ -4896,6 +4896,11 @@
|
||||
"webPushVapidPrivateKeyPlaceholder": "Opcional: introduce una nueva clave privada",
|
||||
"webPushVapidSubject": "Subject de VAPID",
|
||||
"webPushVapidSubjectPlaceholder": "mailto:you@example.com",
|
||||
"webPushVapidGenerate": "Generar claves",
|
||||
"webPushVapidGenerating": "Generando…",
|
||||
"webPushVapidGenerated": "Claves VAPID generadas. Guarda el asunto para terminar.",
|
||||
"webPushVapidGenerateFailed": "No se pudieron generar las claves VAPID.",
|
||||
"webPushVapidGenerateConfirm": "Las claves VAPID ya están configuradas. Generar un par nuevo invalida todas las suscripciones push del navegador: cada dispositivo debe volver a suscribirse. ¿Continuar?",
|
||||
"configured": "Configurado",
|
||||
"monitoringTestSuccess": "Evento de prueba enviado",
|
||||
"monitoringTestFailed": "La prueba de monitorización falló",
|
||||
|
||||
@@ -4896,6 +4896,11 @@
|
||||
"webPushVapidPrivateKeyPlaceholder": "Facultatif : saisissez une nouvelle clé privée",
|
||||
"webPushVapidSubject": "Subject VAPID",
|
||||
"webPushVapidSubjectPlaceholder": "mailto:you@example.com",
|
||||
"webPushVapidGenerate": "Générer les clés",
|
||||
"webPushVapidGenerating": "Génération…",
|
||||
"webPushVapidGenerated": "Clés VAPID générées. Enregistrez le sujet pour terminer.",
|
||||
"webPushVapidGenerateFailed": "Impossible de générer les clés VAPID.",
|
||||
"webPushVapidGenerateConfirm": "Les clés VAPID sont déjà configurées. Générer une nouvelle paire invalide tous les abonnements push du navigateur — chaque appareil doit se réabonner. Continuer ?",
|
||||
"configured": "Configuré",
|
||||
"monitoringTestSuccess": "Événement de test envoyé",
|
||||
"monitoringTestFailed": "Échec du test de surveillance",
|
||||
|
||||
@@ -4896,6 +4896,11 @@
|
||||
"webPushVapidPrivateKeyPlaceholder": "Facoltativo: inserisci una nuova chiave privata",
|
||||
"webPushVapidSubject": "Subject VAPID",
|
||||
"webPushVapidSubjectPlaceholder": "mailto:you@example.com",
|
||||
"webPushVapidGenerate": "Genera chiavi",
|
||||
"webPushVapidGenerating": "Generazione…",
|
||||
"webPushVapidGenerated": "Chiavi VAPID generate. Salva l'oggetto per completare.",
|
||||
"webPushVapidGenerateFailed": "Impossibile generare le chiavi VAPID.",
|
||||
"webPushVapidGenerateConfirm": "Le chiavi VAPID sono già configurate. Generare una nuova coppia invalida tutte le sottoscrizioni push del browser: ogni dispositivo deve ri-sottoscriversi. Continuare?",
|
||||
"configured": "Configurato",
|
||||
"monitoringTestSuccess": "Evento di prova inviato",
|
||||
"monitoringTestFailed": "Test di monitoraggio non riuscito",
|
||||
|
||||
@@ -4896,6 +4896,11 @@
|
||||
"webPushVapidPrivateKeyPlaceholder": "Opcjonalnie: wprowadź nowy klucz prywatny",
|
||||
"webPushVapidSubject": "Subject VAPID",
|
||||
"webPushVapidSubjectPlaceholder": "mailto:you@example.com",
|
||||
"webPushVapidGenerate": "Wygeneruj klucze",
|
||||
"webPushVapidGenerating": "Generowanie…",
|
||||
"webPushVapidGenerated": "Klucze VAPID wygenerowane. Zapisz temat, aby zakończyć.",
|
||||
"webPushVapidGenerateFailed": "Nie udało się wygenerować kluczy VAPID.",
|
||||
"webPushVapidGenerateConfirm": "Klucze VAPID są już skonfigurowane. Wygenerowanie nowej pary unieważnia wszystkie subskrypcje push w przeglądarce — każde urządzenie musi zasubskrybować ponownie. Kontynuować?",
|
||||
"configured": "Skonfigurowano",
|
||||
"monitoringTestSuccess": "Wysłano zdarzenie testowe",
|
||||
"monitoringTestFailed": "Test monitorowania nie powiódł się",
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((value: string) => `enc(${value})`),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateAppSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("web-push", () => ({
|
||||
generateVAPIDKeys: vi.fn(() => ({
|
||||
publicKey: "GENERATED_PUBLIC",
|
||||
privateKey: "GENERATED_PRIVATE",
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
|
||||
};
|
||||
|
||||
function jsonReq(body: unknown): NextRequest {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/admin/settings/web-push-vapid/generate",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/admin/settings/web-push-vapid/generate", () => {
|
||||
it("rejects with 401 when no session (admin-gated)", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(401, "Not authenticated"),
|
||||
);
|
||||
await expect(POST(jsonReq({}))).rejects.toThrow("Not authenticated");
|
||||
});
|
||||
|
||||
it("rejects with 403 for a non-admin (cookie-only boundary)", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(POST(jsonReq({}))).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
it("generates a keypair and stores the private key encrypted", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
|
||||
const res = await POST(jsonReq({}));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
webPushVapidPublicKey: string;
|
||||
webPushVapidSubject: string;
|
||||
webPushVapidConfigured: boolean;
|
||||
};
|
||||
};
|
||||
expect(body.data.webPushVapidPublicKey).toBe("GENERATED_PUBLIC");
|
||||
expect(body.data.webPushVapidConfigured).toBe(true);
|
||||
// Default placeholder subject seeded so the keypair is immediately valid.
|
||||
expect(body.data.webPushVapidSubject).toBe("mailto:admin@example.com");
|
||||
|
||||
// Private key encrypted before persistence; never returned in the body.
|
||||
expect(encrypt).toHaveBeenCalledWith("GENERATED_PRIVATE");
|
||||
expect(JSON.stringify(body.data)).not.toContain("GENERATED_PRIVATE");
|
||||
|
||||
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(upsert?.where).toEqual({ id: "singleton" });
|
||||
expect(upsert?.update).toMatchObject({
|
||||
webPushVapidPublicKey: "GENERATED_PUBLIC",
|
||||
webPushVapidPrivateKeyEncrypted: "enc(GENERATED_PRIVATE)",
|
||||
});
|
||||
});
|
||||
|
||||
it("never logs the plaintext private key in the audit detail", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
await POST(jsonReq({}));
|
||||
expect(auditLog).toHaveBeenCalled();
|
||||
const details = vi.mocked(auditLog).mock.calls[0]?.[1]?.details as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(JSON.stringify(details)).not.toContain("GENERATED_PRIVATE");
|
||||
expect(details.webPushVapidPrivateKeyUpdated).toBe(true);
|
||||
expect(details.replacedExisting).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses with 409 when keys already exist and force is absent", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
webPushVapidPublicKey: "OLD_PUBLIC",
|
||||
webPushVapidPrivateKeyEncrypted: "OLD_ENC",
|
||||
webPushVapidSubject: "mailto:old@example.com",
|
||||
} as never);
|
||||
|
||||
const res = await POST(jsonReq({}));
|
||||
expect(res.status).toBe(409);
|
||||
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("overwrites existing keys when force is true and keeps the subject", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
webPushVapidPublicKey: "OLD_PUBLIC",
|
||||
webPushVapidPrivateKeyEncrypted: "OLD_ENC",
|
||||
webPushVapidSubject: "mailto:keep@example.com",
|
||||
} as never);
|
||||
|
||||
const res = await POST(jsonReq({ force: true }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { webPushVapidSubject: string };
|
||||
};
|
||||
// Existing subject preserved when no override is supplied.
|
||||
expect(body.data.webPushVapidSubject).toBe("mailto:keep@example.com");
|
||||
|
||||
const details = vi.mocked(auditLog).mock.calls[0]?.[1]?.details as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(details.replacedExisting).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an invalid subject override with 422", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
const res = await POST(jsonReq({ subject: "not-a-mailto" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 415 when content-type is not JSON", async () => {
|
||||
const r = new NextRequest(
|
||||
"http://localhost/api/admin/settings/web-push-vapid/generate",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "text/plain" },
|
||||
body: "{}",
|
||||
},
|
||||
);
|
||||
const res = await POST(r);
|
||||
expect(res.status).toBe(415);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { invalidateAppSettings } from "@/lib/cache/invalidate";
|
||||
import { z } from "zod/v4";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const generateSchema = z
|
||||
.object({
|
||||
// Overwrite guard — the client must opt in to replacing an existing
|
||||
// keypair. Regenerating invalidates every browser subscription signed
|
||||
// against the old public key, so the UI confirms before sending this.
|
||||
force: z.boolean().optional(),
|
||||
// Optional subject override. When omitted the existing subject is kept,
|
||||
// or a neutral placeholder seeded so the keypair is immediately usable.
|
||||
subject: z
|
||||
.string()
|
||||
.refine((v) => !v.trim() || /^mailto:.+@.+$/.test(v.trim()), {
|
||||
message: "subject must be in mailto:address@example.com format",
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* POST /api/admin/settings/web-push-vapid/generate
|
||||
*
|
||||
* Server-side VAPID keypair generation — the self-hoster DX win. Calls
|
||||
* `web-push`'s `generateVAPIDKeys()`, encrypts the private key at rest
|
||||
* (`src/lib/crypto.ts`), and persists the pair onto the AppSettings
|
||||
* singleton. Returns only the public key + subject; the private key never
|
||||
* leaves the server and is never logged (the egress redactor covers the
|
||||
* encrypted blob, and the audit detail carries only a "generated" marker).
|
||||
*
|
||||
* Cookie-only via `requireAdmin()` — a Bearer token can never reach it.
|
||||
*
|
||||
* Overwrite guard: if a keypair already exists, the call refuses with 409
|
||||
* unless `force: true` is supplied. Regenerating invalidates existing
|
||||
* browser subscriptions, so the operator must opt in.
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.webPushVapid.generate" } });
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = generateSchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
}
|
||||
|
||||
const existing = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
webPushVapidPublicKey: true,
|
||||
webPushVapidPrivateKeyEncrypted: true,
|
||||
webPushVapidSubject: true,
|
||||
},
|
||||
});
|
||||
|
||||
const alreadyConfigured = Boolean(
|
||||
existing?.webPushVapidPublicKey &&
|
||||
existing?.webPushVapidPrivateKeyEncrypted,
|
||||
);
|
||||
|
||||
if (alreadyConfigured && parsed.data.force !== true) {
|
||||
// Overwrite guard — surface the existing public key so the UI can warn
|
||||
// the operator that regenerating invalidates current subscriptions.
|
||||
return apiError(
|
||||
"VAPID keys already configured. Regenerating invalidates existing browser subscriptions. Resend with force to replace them.",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
// Lazy import — mirrors the sender so a build without `web-push` still
|
||||
// type-checks. `generateVAPIDKeys()` returns Base64URL public + private.
|
||||
const webpush = await import("web-push");
|
||||
const { publicKey, privateKey } = webpush.generateVAPIDKeys();
|
||||
|
||||
// Subject precedence: explicit override → existing subject → placeholder.
|
||||
// The placeholder keeps the keypair immediately valid; the admin edits
|
||||
// the real contact address inline in the card afterwards.
|
||||
const subjectOverride = parsed.data.subject?.trim();
|
||||
const subject =
|
||||
(subjectOverride && subjectOverride.length > 0
|
||||
? subjectOverride
|
||||
: existing?.webPushVapidSubject) || "mailto:admin@example.com";
|
||||
|
||||
await prisma.appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
update: {
|
||||
webPushVapidPublicKey: publicKey,
|
||||
webPushVapidPrivateKeyEncrypted: encrypt(privateKey),
|
||||
webPushVapidSubject: subject,
|
||||
},
|
||||
create: {
|
||||
id: "singleton",
|
||||
webPushVapidPublicKey: publicKey,
|
||||
webPushVapidPrivateKeyEncrypted: encrypt(privateKey),
|
||||
webPushVapidSubject: subject,
|
||||
},
|
||||
});
|
||||
|
||||
invalidateAppSettings();
|
||||
|
||||
await auditLog("admin.web_push_vapid.generate", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
// Never the private key — only a marker that a fresh pair was minted
|
||||
// and whether it replaced an existing one.
|
||||
details: {
|
||||
webPushVapidPrivateKeyUpdated: true,
|
||||
replacedExisting: alreadyConfigured,
|
||||
webPushVapidSubject: subject,
|
||||
},
|
||||
});
|
||||
|
||||
// Public key + subject only; the private key stays server-side.
|
||||
return apiSuccess({
|
||||
webPushVapidPublicKey: publicKey,
|
||||
webPushVapidSubject: subject,
|
||||
webPushVapidConfigured: true,
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { BellRing, Loader2 } from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { BellRing, KeyRound, Loader2 } from "lucide-react";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
ConfiguredBadge,
|
||||
PasswordInput,
|
||||
@@ -16,6 +20,7 @@ import {
|
||||
|
||||
export function WebPushVapidSection() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: settings } = useAdminSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
const [webPushVapidPublicKeyDraft, setWebPushVapidPublicKeyDraft] = useState<
|
||||
@@ -26,6 +31,7 @@ export function WebPushVapidSection() {
|
||||
const [webPushVapidSubjectDraft, setWebPushVapidSubjectDraft] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const webPushVapidPublicKeyValue =
|
||||
webPushVapidPublicKeyDraft ?? settings?.webPushVapidPublicKey ?? "";
|
||||
@@ -52,6 +58,53 @@ export function WebPushVapidSection() {
|
||||
});
|
||||
}
|
||||
|
||||
async function generateVapidKeys(force: boolean) {
|
||||
setGenerating(true);
|
||||
try {
|
||||
const res = await apiFetchRaw(
|
||||
"/api/admin/settings/web-push-vapid/generate",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(force ? { force: true } : {}),
|
||||
},
|
||||
);
|
||||
|
||||
if (res.status === 409) {
|
||||
// Overwrite guard — existing keys would be replaced. Confirm with
|
||||
// the operator (regenerating invalidates current subscriptions),
|
||||
// then retry with force.
|
||||
if (window.confirm(t("admin.webPushVapidGenerateConfirm"))) {
|
||||
await generateVapidKeys(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
toast.error(t("admin.webPushVapidGenerateFailed"));
|
||||
return;
|
||||
}
|
||||
|
||||
const json = (await res.json()) as {
|
||||
data: { webPushVapidPublicKey: string; webPushVapidSubject: string };
|
||||
};
|
||||
// The private key was minted and encrypted server-side; only the
|
||||
// public key + subject come back. Populate the visible fields and
|
||||
// leave the private-key input empty (it stays "configured").
|
||||
setWebPushVapidPublicKeyDraft(json.data.webPushVapidPublicKey);
|
||||
setWebPushVapidSubjectDraft(json.data.webPushVapidSubject);
|
||||
setWebPushVapidPrivateKeyDraft("");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminSettings(),
|
||||
});
|
||||
toast.success(t("admin.webPushVapidGenerated"));
|
||||
} catch {
|
||||
toast.error(t("admin.webPushVapidGenerateFailed"));
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
@@ -122,6 +175,21 @@ export function WebPushVapidSection() {
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void generateVapidKeys(false)}
|
||||
disabled={generating || updateSettings.isPending}
|
||||
>
|
||||
{generating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<KeyRound className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{generating
|
||||
? t("admin.webPushVapidGenerating")
|
||||
: t("admin.webPushVapidGenerate")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveWebPushVapidSettings}
|
||||
|
||||
Reference in new issue
Block a user