Files
HealthLog/src/app/api/notifications/web-push/test/route.ts
T
Marc-André Bombeck ca03c6099f chore(release): v1.4.24
Security + accessibility hardening release. Closes the highest-priority
findings from a focused security audit of the v1.4.23 release:

- Bearer auth fail-closed when a route declares no scope (api-handler)
- Public /api/send Umami proxy now rate-limits 120 req/min/IP
- Proxy nonce generation switched to btoa(...) for Edge portability
- Session-expiry delete race uses deleteMany + catch (no more 500s)
- POST /api/import returns the standard apiError envelope on 422
- Runtime Docker image pins Prisma 7.8 + worker deps to minors
- Notifications page renders a real load-error alert
- Accessibility: switch aria-labels, htmlFor pairing, mobile admin
  Overview link, deep-link anchors on notification settings
- Chart overlays: per-chart comparisonBaseline pref, BMI/bodyFat/
  sleep/steps overlay keys, TrendCard layout tolerates long labels

Verification: pnpm typecheck && pnpm lint && pnpm test (2244/2244)
&& pnpm format:check all green.
2026-05-12 22:33:35 +02:00

105 lines
3.1 KiB
TypeScript

import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { getVapidConfig } from "@/lib/notifications/vapid-config";
export const dynamic = "force-dynamic";
function hostFromEndpoint(endpoint: string): string {
try {
return new URL(endpoint).host;
} catch {
return "unknown";
}
}
// Strip any URL from a stringified web-push library error. Push providers (FCM,
// Mozilla autopush, etc.) embed the full subscription endpoint into 410/404
// error messages — that endpoint token is the routing secret, so it must never
// land in our Wide Events.
function redactPushError(text: string): string {
return text.replace(/https?:\/\/\S+/gi, "[endpoint]");
}
export const POST = apiHandler(async (request: NextRequest) => {
void request;
const { user } = await requireAuth();
annotate({ action: { name: "notifications.web-push.test" } });
const rl = await checkRateLimit(`web-push-test:${user.id}`, 5, 60_000);
if (!rl.allowed) {
return apiError("Too many test requests", 429, {
errorCode: "rate_limited_self",
});
}
// Spec: only the most-recent subscription receives the test push. A user
// with phone + tablet + desktop should not get three buzzes per click.
const subscription = await prisma.pushSubscription.findFirst({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
});
if (!subscription) {
return apiError("No push subscriptions registered", 422, {
errorCode: "not_configured",
});
}
const config = await getVapidConfig();
if (!config) {
return apiError("VAPID keys not configured", 422, {
errorCode: "vapid_not_configured",
});
}
const webpush = await import("web-push");
webpush.setVapidDetails(config.subject, config.publicKey, config.privateKey);
const pushPayload = JSON.stringify({
title: "HealthLog",
body: "This is a test notification from HealthLog",
tag: "self-test",
});
const host = hostFromEndpoint(subscription.endpoint);
const start = performance.now();
try {
const p256dh = decrypt(subscription.p256dh);
const auth = decrypt(subscription.auth);
const result = await webpush.sendNotification(
{ endpoint: subscription.endpoint, keys: { p256dh, auth } },
pushPayload,
);
const latencyMs = Math.round(performance.now() - start);
return apiSuccess({
ok: true,
sent: 1,
latencyMs,
perEndpoint: [{ host, status: result.statusCode ?? 201 }],
});
} catch (err: unknown) {
const status = (err as { statusCode?: number }).statusCode ?? null;
const message = redactPushError((err as Error).message ?? "").slice(0, 200);
annotate({
meta: {
web_push_test_status: status,
web_push_test_host: host,
web_push_test_error: message,
},
});
return apiSuccess({
ok: false,
sent: 0,
perEndpoint: [{ host, status }],
});
}
});