mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
feat(notifications): generate VAPID keys from the admin panel
Add a server-side VAPID keypair generator so a self-hoster no longer has to run a shell command to enable Web Push. A new admin-only route (POST /api/admin/settings/web-push-vapid/generate) calls web-push's generateVAPIDKeys(), stores the private key encrypted at rest, seeds a placeholder mailto: subject the operator can edit, and returns only the public key. The route is cookie-only via requireAdmin(); a Bearer token cannot reach it. The private key is never returned and never logged — the audit detail carries a marker only. The admin Web Push VAPID card gains a Generate keys button that fills the public key and subject fields on success. When a keypair already exists the route refuses with 409 and the card confirms before retrying with force, warning that regenerating invalidates existing browser subscriptions.
This commit is contained in:
1 parent
45ccdcb305
commit
d0ba4b061b
9 files changed
+423
-1
No files matched your search
@@ -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,
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user