Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/api/openapi.yaml
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/*`
Expand Down
13 changes: 13 additions & 0 deletions src/app/api/cycle/symptoms/custom/[key]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
14 changes: 14 additions & 0 deletions src/app/api/measurement-reminders/[id]/complete/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> };

Expand All @@ -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 },
Expand Down
145 changes: 145 additions & 0 deletions src/app/api/measurement-reminders/[id]/satisfy/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
14 changes: 14 additions & 0 deletions src/app/api/measurement-reminders/[id]/satisfy/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> };

Expand All @@ -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 },
Expand Down
5 changes: 5 additions & 0 deletions src/app/api/mood/tags/[key]/hidden/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
9 changes: 9 additions & 0 deletions src/app/api/mood/tags/__tests__/custom-crud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down Expand Up @@ -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 }),
}),
);
});
});

Expand Down
8 changes: 8 additions & 0 deletions src/app/api/mood/tags/__tests__/groups-crud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading
Loading