From 311adcfdda7b226f322162eb8a26d19c5b6cd745 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 14:18:56 +0200 Subject: [PATCH 1/3] Refuse a manual satisfy of a screening reminder server-side A Vorsorge screening (PHQ-9, GAD-7, WHO-5, SCI) resolves only from the score row a completed check-in writes, never from a manual done. The web and iOS clients enforce this by routing a screening to the check-in page instead of offering the done action, but the satisfy and complete routes trusted that gate and would stamp any reminder, screening included. A crafted POST could therefore mark a screening satisfied with no assessment behind it. Both routes now reject a screening reminder with 409 before the shared satisfy primitive runs, mirroring the client rule as defense in depth. Typed numeric reminders keep their manual satisfy, which is intentional and covered. The satisfy route gains its first route test alongside the new guard case on complete. --- .../[id]/complete/__tests__/route.test.ts | 29 ++++ .../[id]/complete/route.ts | 14 ++ .../[id]/satisfy/__tests__/route.test.ts | 145 ++++++++++++++++++ .../[id]/satisfy/route.ts | 14 ++ 4 files changed, 202 insertions(+) create mode 100644 src/app/api/measurement-reminders/[id]/satisfy/__tests__/route.test.ts diff --git a/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts b/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts index 5a48a0bf1..c6aa2b7ca 100644 --- a/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts +++ b/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts @@ -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); + }); }); diff --git a/src/app/api/measurement-reminders/[id]/complete/route.ts b/src/app/api/measurement-reminders/[id]/complete/route.ts index ce3a8d1fb..23fcc71b6 100644 --- a/src/app/api/measurement-reminders/[id]/complete/route.ts +++ b/src/app/api/measurement-reminders/[id]/complete/route.ts @@ -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 }, diff --git a/src/app/api/measurement-reminders/[id]/satisfy/__tests__/route.test.ts b/src/app/api/measurement-reminders/[id]/satisfy/__tests__/route.test.ts new file mode 100644 index 000000000..796291334 --- /dev/null +++ b/src/app/api/measurement-reminders/[id]/satisfy/__tests__/route.test.ts @@ -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: 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); + }); +}); diff --git a/src/app/api/measurement-reminders/[id]/satisfy/route.ts b/src/app/api/measurement-reminders/[id]/satisfy/route.ts index dc24728fb..7af4ccbb7 100644 --- a/src/app/api/measurement-reminders/[id]/satisfy/route.ts +++ b/src/app/api/measurement-reminders/[id]/satisfy/route.ts @@ -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 }, From fe17d06572c8d929785b1c37ae4b461fb04af811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 14:57:12 +0200 Subject: [PATCH 2/3] Give mood-tag and cycle-symptom edits an audit row Editing a custom mood tag or group, and editing a custom cycle symptom, wrote nothing to the audit ledger, while their delete siblings did. Two of these paths destroy data: a custom mood tag or cycle symptom purged with ?purge=true cascades its entry links and unpicks historical entries, so a destruction left no trace of who ran it or when. Every vocabulary-mutating verb now records an audit row: create, edit, and delete for both custom tags and custom groups, and edit for a custom cycle symptom. Encrypted labels are never logged; the row carries only the field names touched and the resulting active state. The two display-only writes on this surface, hiding a catalogue tag and reordering the layout, stay ledger-free by design and say so inline, since a per-user display preference is not a health-data write and a row on every drag-to-reorder would be noise rather than a record. --- .../api/cycle/symptoms/custom/[key]/route.ts | 13 ++++++++++++ .../custom/__tests__/custom-crud.test.ts | 9 ++++++++ src/app/api/mood/tags/[key]/hidden/route.ts | 5 +++++ .../mood/tags/__tests__/custom-crud.test.ts | 9 ++++++++ .../mood/tags/__tests__/groups-crud.test.ts | 8 +++++++ src/app/api/mood/tags/custom/[key]/route.ts | 21 +++++++++++++++++++ src/app/api/mood/tags/custom/route.ts | 10 +++++++++ src/app/api/mood/tags/groups/[key]/route.ts | 21 +++++++++++++++++++ src/app/api/mood/tags/groups/route.ts | 10 +++++++++ src/app/api/mood/tags/layout/route.ts | 5 +++++ 10 files changed, 111 insertions(+) diff --git a/src/app/api/cycle/symptoms/custom/[key]/route.ts b/src/app/api/cycle/symptoms/custom/[key]/route.ts index 7db44e80f..d11b7df09 100644 --- a/src/app/api/cycle/symptoms/custom/[key]/route.ts +++ b/src/app/api/cycle/symptoms/custom/[key]/route.ts @@ -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({ diff --git a/src/app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts b/src/app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts index b093d3a64..eec847a26 100644 --- a/src/app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts +++ b/src/app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts @@ -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 () => { diff --git a/src/app/api/mood/tags/[key]/hidden/route.ts b/src/app/api/mood/tags/[key]/hidden/route.ts index fb7dc4d6a..c1ec8157c 100644 --- a/src/app/api/mood/tags/[key]/hidden/route.ts +++ b/src/app/api/mood/tags/[key]/hidden/route.ts @@ -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 }, diff --git a/src/app/api/mood/tags/__tests__/custom-crud.test.ts b/src/app/api/mood/tags/__tests__/custom-crud.test.ts index 24e5e363e..e3c8d83da 100644 --- a/src/app/api/mood/tags/__tests__/custom-crud.test.ts +++ b/src/app/api/mood/tags/__tests__/custom-crud.test.ts @@ -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 }), + }), + ); }); }); diff --git a/src/app/api/mood/tags/__tests__/groups-crud.test.ts b/src/app/api/mood/tags/__tests__/groups-crud.test.ts index a66d250c8..ce4ca9ad9 100644 --- a/src/app/api/mood/tags/__tests__/groups-crud.test.ts +++ b/src/app/api/mood/tags/__tests__/groups-crud.test.ts @@ -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 () => { diff --git a/src/app/api/mood/tags/custom/[key]/route.ts b/src/app/api/mood/tags/custom/[key]/route.ts index 3b28228e7..2c46dd878 100644 --- a/src/app/api/mood/tags/custom/[key]/route.ts +++ b/src/app/api/mood/tags/custom/[key]/route.ts @@ -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 }); diff --git a/src/app/api/mood/tags/custom/route.ts b/src/app/api/mood/tags/custom/route.ts index 4aece227e..713099674 100644 --- a/src/app/api/mood/tags/custom/route.ts +++ b/src/app/api/mood/tags/custom/route.ts @@ -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 }, diff --git a/src/app/api/mood/tags/groups/[key]/route.ts b/src/app/api/mood/tags/groups/[key]/route.ts index bdc07e508..61bbf92e5 100644 --- a/src/app/api/mood/tags/groups/[key]/route.ts +++ b/src/app/api/mood/tags/groups/[key]/route.ts @@ -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 }, diff --git a/src/app/api/mood/tags/groups/route.ts b/src/app/api/mood/tags/groups/route.ts index b0b385809..beb867ba0 100644 --- a/src/app/api/mood/tags/groups/route.ts +++ b/src/app/api/mood/tags/groups/route.ts @@ -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 }, diff --git a/src/app/api/mood/tags/layout/route.ts b/src/app/api/mood/tags/layout/route.ts index 2e1ea0464..3294d78a0 100644 --- a/src/app/api/mood/tags/layout/route.ts +++ b/src/app/api/mood/tags/layout/route.ts @@ -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: { From 976c14b2237f10247101fffb0d6237ca2f7241d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 18:01:26 +0200 Subject: [PATCH 3/3] =?UTF-8?q?chore(release):=20v1.37.6=20=E2=80=94=20the?= =?UTF-8?q?=20server-side=20safety=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two hardening changes collected on the trunk: a screening reminder can no longer be marked done through a crafted request, and editing a custom mood tag, tag group or cycle symptom now leaves an audit entry the way deleting one already did. --- CHANGELOG.md | 10 ++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 699222b79..49ee651a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 46606fc1f..860444943 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/package.json b/package.json index 543d9c806..e3932cb05 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index 1c3f94e96..44db57c65 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*`