Merge pull request #765 from MBombeck/release/v1.37.6

release/v1.37.6
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-09 19:05:26 +02:00
commit 9e20b32154
18 files changed
+326 -3

No files matched your search

+10
View File
@@ -1,5 +1,15 @@
# Changelog
## [1.37.6] — 2026-08-09
### Security
- A mental-wellbeing screening reminder (PHQ-9, GAD-7, WHO-5, SCI) can no longer be marked done through a crafted request. A screening resolves only from the score a completed check-in produces, so the satisfy and complete routes now refuse a screening reminder outright rather than trusting that the app never offers that action for one.
### Changed
- Editing a custom mood tag, a custom tag group or a custom cycle symptom now records an audit entry, the same way deleting one already did. Removing a custom tag or symptom together with its history leaves a trace of the change. Hiding a catalogue tag and reordering the layout stay out of the audit log by design, since those are display preferences rather than a change to your record.
## [1.37.5] — 2026-08-09
### Changed
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.37.5
version: 1.37.6
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.37.5",
"version": "1.37.6",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.37.5";
/* @sw-version-fallback */ "v1.37.6";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -101,6 +101,19 @@ export const PATCH = apiHandler(
select: { key: true, icon: true, isActive: true, labelEncrypted: true },
});
// Symmetry with the DELETE sibling: an edit to a custom symptom leaves a
// ledger row too. The decrypted label is never logged (see the docstring);
// we record only which fields the write touched and the resulting active
// state, so the trace stays free of reproductive free text.
await auditLog("cycle.symptom.custom.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
fields: Object.keys(parsed.data),
isActive: updated.isActive,
},
});
annotate({ action: { name: "cycle.symptom.custom.update" } });
return apiSuccess({
@@ -50,6 +50,7 @@ import { PATCH, DELETE } from "../[key]/route";
import { getSession } from "@/lib/auth/session";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { apiError } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -215,6 +216,14 @@ describe("PATCH/DELETE /api/cycle/symptoms/custom/:key", () => {
);
expect(res.status).toBe(200);
expect(db.cycleSymptom.count).not.toHaveBeenCalled();
// An edit leaves a ledger row too, symmetric with the DELETE sibling.
// The decrypted label is never in the audit details, only the field names.
expect(vi.mocked(auditLog)).toHaveBeenCalledWith(
"cycle.symptom.custom.update",
expect.objectContaining({
details: expect.objectContaining({ fields: expect.any(Array) }),
}),
);
});
it("soft-deactivates by default and hard-deletes on ?purge=true", async () => {
@@ -144,4 +144,33 @@ describe("POST /api/measurement-reminders/[id]/complete", () => {
expect(res.status).toBe(404);
expect(satisfyReminderMock).not.toHaveBeenCalled();
});
it("refuses a screening reminder with 409 and never satisfies (defense-in-depth)", async () => {
// A crafted completion of a PHQ-9 screening: the client never offers this
// (it routes to /mental-wellbeing), and a screening may only resolve from
// the server-written score row. The route must not let it read as done.
findFirstMock.mockResolvedValue({ ...ROW, measurementType: "PHQ9_SCORE" });
const res = await POST(makeRequest(), params);
expect(res.status).toBe(409);
expect(satisfyReminderMock).not.toHaveBeenCalled();
});
it("still allows a manual completion of a typed numeric reminder", async () => {
// Typed numeric reminders (a BP row here) legitimately support a manual
// "complete"; the screening guard must not catch them.
findFirstMock.mockResolvedValue(ROW); // measurementType: BLOOD_PRESSURE_SYS
satisfyReminderMock.mockResolvedValue({
satisfied: true,
nextDueAt: new Date("2026-06-25T07:00:00Z"),
});
findUniqueOrThrowMock.mockResolvedValue({
...ROW,
lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"),
});
const res = await POST(makeRequest(), params);
expect(res.status).toBe(200);
expect(satisfyReminderMock).toHaveBeenCalledTimes(1);
});
});
@@ -29,6 +29,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
import { isScreeningReminderType } from "@/lib/validations/measurement-reminders";
type RouteParams = { params: Promise<{ id: string }> };
@@ -53,6 +54,19 @@ export const POST = apiHandler(
return apiError("Measurement reminder not found", 404);
}
// Defense-in-depth: a screening reminder (PHQ-9 / GAD-7 / WHO-5 / SCI)
// resolves ONLY from the server-written *_SCORE row a completed check-in
// produces — never from a manual "complete." The client routes a screening
// to `/mental-wellbeing` instead of offering this action, so a completion
// landing here is a crafted request; refusing it keeps a screening from
// reading as completed with no assessment behind it.
if (isScreeningReminderType(existing.measurementType)) {
return apiError(
"Screening reminders resolve from a completed check-in, not a manual completion",
409,
);
}
const userRow = await prisma.user.findUnique({
where: { id: user.id },
select: { timezone: true },
@@ -0,0 +1,145 @@
/**
* v1.17.1 — manual "Erledigt" (satisfy) route.
*
* Covers: a free-text Vorsorge resolves through the shared primitive,
* owner-scoped 404 on a cross-user / tombstoned id, and the defense-in-depth
* screening refusal (a screening reminder may only resolve from the
* server-written score row, never a crafted manual satisfy).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
requireRecordAuth: vi.fn(async () => ({
user: { id: "u1", locale: "en" },
actor: { id: "u1", locale: "en" },
grantId: null,
})),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
const satisfyReminderMock = vi.fn();
vi.mock("@/lib/measurement-reminders/satisfy", () => ({
satisfyReminder: (...args: unknown[]) => satisfyReminderMock(...args),
}));
const findFirstMock = vi.fn();
const findUserMock = vi.fn();
const findUniqueOrThrowMock = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
measurementReminder: {
findFirst: (...a: unknown[]) => findFirstMock(...a),
findUniqueOrThrow: (...a: unknown[]) => findUniqueOrThrowMock(...a),
},
user: { findUnique: (...a: unknown[]) => findUserMock(...a) },
},
}));
import { POST } from "../route";
const ROW = {
id: "r1",
userId: "u1",
label: "Blutbild",
measurementType: null, // free-text Vorsorge (resolves only on a manual satisfy)
intervalDays: 365,
rrule: null,
anchorDate: null,
endsOn: null,
origin: "VORSORGE",
notifyHour: 9,
location: null,
nextDueAt: new Date("2026-06-25T07:00:00Z"),
lastSatisfiedAt: null,
enabled: true,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-18T00:00:00Z"),
deletedAt: null,
};
function makeRequest(): NextRequest {
return new NextRequest(
"http://localhost/api/measurement-reminders/r1/satisfy",
{ method: "POST" },
);
}
const params = { params: Promise.resolve({ id: "r1" }) };
beforeEach(() => {
vi.clearAllMocks();
findUserMock.mockResolvedValue({ timezone: "Europe/Berlin" });
});
describe("POST /api/measurement-reminders/[id]/satisfy", () => {
it("satisfies a free-text Vorsorge via the shared primitive", async () => {
findFirstMock.mockResolvedValue(ROW);
satisfyReminderMock.mockResolvedValue({
satisfied: true,
nextDueAt: new Date("2027-06-25T07:00:00Z"),
});
findUniqueOrThrowMock.mockResolvedValue({
...ROW,
lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"),
});
const res = await POST(makeRequest(), params);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.error).toBeNull();
expect(body.data.id).toBe("r1");
expect(satisfyReminderMock).toHaveBeenCalledTimes(1);
});
it("is owner-scoped: a cross-user reminder 404s and never satisfies", async () => {
findFirstMock.mockResolvedValue({ ...ROW, userId: "someone-else" });
const res = await POST(makeRequest(), params);
expect(res.status).toBe(404);
expect(satisfyReminderMock).not.toHaveBeenCalled();
});
it("404s a tombstoned / missing reminder", async () => {
findFirstMock.mockResolvedValue(null);
const res = await POST(makeRequest(), params);
expect(res.status).toBe(404);
expect(satisfyReminderMock).not.toHaveBeenCalled();
});
it("refuses a screening reminder with 409 and never satisfies (defense-in-depth)", async () => {
findFirstMock.mockResolvedValue({ ...ROW, measurementType: "GAD7_SCORE" });
const res = await POST(makeRequest(), params);
expect(res.status).toBe(409);
expect(satisfyReminderMock).not.toHaveBeenCalled();
});
it("still allows a manual satisfy of a typed numeric reminder", async () => {
findFirstMock.mockResolvedValue({
...ROW,
measurementType: "BLOOD_PRESSURE_SYS",
});
satisfyReminderMock.mockResolvedValue({
satisfied: true,
nextDueAt: new Date("2026-07-02T07:00:00Z"),
});
findUniqueOrThrowMock.mockResolvedValue({
...ROW,
measurementType: "BLOOD_PRESSURE_SYS",
lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"),
});
const res = await POST(makeRequest(), params);
expect(res.status).toBe(200);
expect(satisfyReminderMock).toHaveBeenCalledTimes(1);
});
});
@@ -15,6 +15,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
import { isScreeningReminderType } from "@/lib/validations/measurement-reminders";
type RouteParams = { params: Promise<{ id: string }> };
@@ -39,6 +40,19 @@ export const POST = apiHandler(
return apiError("Measurement reminder not found", 404);
}
// Defense-in-depth: a screening reminder (PHQ-9 / GAD-7 / WHO-5 / SCI)
// resolves ONLY from the server-written *_SCORE row a completed check-in
// produces — never from a manual "done." The client already routes a
// screening to `/mental-wellbeing` instead of offering this action, so a
// satisfy landing here is a crafted request; refusing it keeps a screening
// from reading as completed with no assessment behind it.
if (isScreeningReminderType(existing.measurementType)) {
return apiError(
"Screening reminders resolve from a completed check-in, not a manual satisfy",
409,
);
}
const userRow = await prisma.user.findUnique({
where: { id: user.id },
select: { timezone: true },
@@ -62,6 +62,11 @@ export const PUT = apiHandler(
});
}
// No audit row by design: hiding or showing a catalogue tag is a per-user
// display preference, not a health-data write. It destroys nothing (the
// catalogue tag and every entry that used it are untouched), so a ledger
// row here would be noise rather than forensics. The wide-event annotate
// still carries it for observability.
annotate({
action: { name: "mood.tag.hidden.set" },
meta: { hidden: parsed.data.hidden },
@@ -44,6 +44,7 @@ import { POST } from "../custom/route";
import { PATCH, DELETE } from "../custom/[key]/route";
import { PUT } from "../[key]/hidden/route";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -247,6 +248,14 @@ describe("PATCH/DELETE /api/mood/tags/custom/:key", () => {
);
expect(purge.status).toBe(200);
expect(db.moodTag.delete).toHaveBeenCalledWith({ where: { id: "id1" } });
// A purge cascades `mood_entry_tag_links` and unpicks history, so it must
// leave a ledger row recording that it happened.
expect(vi.mocked(auditLog)).toHaveBeenCalledWith(
"mood.tag.custom.delete",
expect.objectContaining({
details: expect.objectContaining({ purge: true }),
}),
);
});
});
@@ -55,6 +55,7 @@ vi.mock("next/headers", () => ({
import { POST } from "../groups/route";
import { PATCH, DELETE } from "../groups/[key]/route";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { CUSTOM_CATEGORY_ID } from "@/lib/mood/custom-tags";
const SESSION_OK = {
@@ -228,6 +229,13 @@ describe("DELETE /api/mood/tags/groups/:key", () => {
expect(db.moodTagCategory.update).not.toHaveBeenCalled();
// Re-home still ran first — purge removes the group, never the tags.
expect(db.moodTag.updateMany).toHaveBeenCalled();
// A group delete/purge leaves a ledger row recording purge + re-home.
expect(vi.mocked(auditLog)).toHaveBeenCalledWith(
"mood.tag.group.delete",
expect.objectContaining({
details: expect.objectContaining({ purge: true }),
}),
);
});
it("404s a non-custom key and a foreign group", async () => {
@@ -4,11 +4,13 @@ import { prisma } from "@/lib/db";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
updateCustomTagSchema,
encryptCustomLabel,
@@ -89,6 +91,17 @@ export const PATCH = apiHandler(
},
});
// The custom label is encrypted at rest and never logged; the ledger row
// records only which fields the edit touched and the resulting active state.
await auditLog("mood.tag.custom.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
fields: Object.keys(parsed.data),
isActive: updated.isActive,
},
});
annotate({ action: { name: "mood.tag.custom.update" } });
return apiSuccess({
@@ -133,6 +146,14 @@ export const DELETE = apiHandler(
});
}
// A purge hard-deletes the tag and cascades its `mood_entry_tag_links`,
// unpicking historical entries — a destruction that must leave a ledger row.
await auditLog("mood.tag.custom.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { purge },
});
annotate({ action: { name: "mood.tag.custom.delete" }, meta: { purge } });
return apiSuccess({ key, purged: purge });
+10
View File
@@ -4,11 +4,13 @@ import { prisma } from "@/lib/db";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
createCustomTagSchema,
mintCustomTagKey,
@@ -86,6 +88,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
// The custom label is encrypted at rest and never logged; the ledger row
// records the mint (icon only) so a created tag leaves a trace.
await auditLog("mood.tag.custom.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: { icon: created.icon },
});
annotate({
action: { name: "mood.tag.custom.create" },
meta: { icon: created.icon },
@@ -4,11 +4,13 @@ import { prisma, toJson } from "@/lib/db";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
updateCustomGroupSchema,
encryptCustomLabel,
@@ -72,6 +74,17 @@ export const PATCH = apiHandler(
},
});
// The group label is encrypted at rest and never logged; the ledger row
// records only which fields changed and the resulting active state.
await auditLog("mood.tag.group.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
fields: Object.keys(parsed.data),
isActive: updated.isActive,
},
});
annotate({ action: { name: "mood.tag.group.update" } });
return apiSuccess({
@@ -144,6 +157,14 @@ export const DELETE = apiHandler(
return rehomed.count;
});
// Non-destructive to tags and links (they re-home), but the group row is
// retired or purged; the ledger records which, and how many tags re-homed.
await auditLog("mood.tag.group.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { purge, rehomedCount },
});
annotate({
action: { name: "mood.tag.group.delete" },
meta: { purge, rehomed_count: rehomedCount },
+10
View File
@@ -4,11 +4,13 @@ import { prisma } from "@/lib/db";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
createCustomGroupSchema,
mintCustomCategoryKey,
@@ -67,6 +69,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
select: { key: true, icon: true },
});
// The group label is encrypted at rest and never logged; the ledger row
// records the mint (icon only) so a created group leaves a trace.
await auditLog("mood.tag.group.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: { icon: created.icon },
});
annotate({
action: { name: "mood.tag.group.create" },
meta: { icon: created.icon },
+5
View File
@@ -121,6 +121,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
});
if ("conflict" in guarded) return guarded.conflict;
// No audit row by design: the layout blob is a per-user display ordering
// (group order + tag placements), not a health-data write. It destroys
// nothing and fires on every drag-to-reorder save, so a ledger row here
// would be noise rather than forensics. The wide-event annotate still
// carries it for observability.
annotate({
action: { name: "mood.tag.layout.update" },
meta: {