diff --git a/.dockerignore b/.dockerignore
index 33b4697ce..4239f361a 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -78,8 +78,9 @@ CONTRIBUTING.md
CONTRIBUTING-AI.md
CODE_OF_CONDUCT.md
SECURITY.md
-# LICENSE / LICENSE.md are deliberately NOT ignored: AGPL-3.0 conveys with
-# the software, so the licence text ships inside the image.
+# LICENSE / LICENSE.md are deliberately NOT ignored: the PolyForm
+# Noncommercial 1.0.0 notice terms require the licence text (or its URL)
+# to ship with every copy of the software, so it ships inside the image.
AGENTS.md
.planning
docs
diff --git a/.env.production.example b/.env.production.example
index 0cfdc6a33..e4f73447e 100644
--- a/.env.production.example
+++ b/.env.production.example
@@ -65,6 +65,9 @@ API_TOKEN_HMAC_KEY=""
# Deploy webhook -- Coolify -> HealthLog auto-deploy feedback
# -----------------------------------------------------------------------------
# DEPLOY_WEBHOOK_SECRET=""
+# Optional: deployment-dashboard URL (e.g. the Coolify UI) linked in
+# deploy-failure notifications. When unset the notification omits the link.
+# DEPLOY_LOGS_URL=""
# -----------------------------------------------------------------------------
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index b92a36a19..0d32ac4c7 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -107,7 +107,7 @@ jobs:
labels: |
org.opencontainers.image.title=HealthLog
org.opencontainers.image.description=Self-hosted health-tracking PWA
- org.opencontainers.image.licenses=AGPL-3.0-only
+ org.opencontainers.image.licenses=PolyForm-Noncommercial-1.0.0
org.opencontainers.image.source=https://github.com/${{ github.repository }}
# v1.4.27 B3 — GeoLite2-City + GeoLite2-ASN databases are too large
@@ -299,7 +299,7 @@ jobs:
labels: |
org.opencontainers.image.title=HealthLog
org.opencontainers.image.description=Self-hosted health-tracking PWA
- org.opencontainers.image.licenses=AGPL-3.0-only
+ org.opencontainers.image.licenses=PolyForm-Noncommercial-1.0.0
org.opencontainers.image.source=https://github.com/${{ github.repository }}
- name: Create multi-arch manifest
@@ -313,15 +313,15 @@ jobs:
run: |
docker buildx imagetools inspect '${{ env.IMAGE_REF }}:${{ steps.meta.outputs.version }}'
- # Phase C2 (v1.4.15): trigger Coolify deploy automatically once GHCR has
- # the new image. Removes the manual SSH + force-pull recipe documented
- # in `.planning/phase-8-report.md`.
+ # v1.4.15: trigger the Coolify deploy automatically once GHCR has
+ # the new image. Replaces the former manual SSH + force-pull recipe
+ # on the deployment host.
#
# Runs only on the publish path (push to `main` or to a `v*` tag), never
# on PRs. `continue-on-error: true` because the image is *already*
# published to GHCR by the time we reach this step — a Coolify-side
- # outage must not retroactively fail the build. Marc still has the
- # manual recipe as fallback.
+ # outage must not retroactively fail the build. The maintainer still
+ # has the manual recipe as fallback.
#
# Required GitHub repository secrets (set by the maintainer once):
# COOLIFY_WEBHOOK = full deploy URL incl. ?uuid=...&force=false
@@ -377,8 +377,8 @@ jobs:
# new digest. The webhook fires inside that window, so
# Coolify pulls "latest" while the edge still serves the
# prior digest, gets "no change", and returns success.
- # See `.planning/round-coolify-auto-deploy-fix-2026-05-16.md`
- # for the full root-cause + remediation note.
+ # Waiting out the propagation window before firing the
+ # webhook closes that gap.
echo "Waiting 90 s for GHCR cache propagation before triggering Coolify (image: $IMAGE_VERSION)…"
sleep 90
# v1.4.22 C3 — force=true tells Coolify to skip its image-cache
@@ -405,8 +405,8 @@ jobs:
echo "Coolify response (HTTP $status):"
echo "$body"
if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then
- echo "::error::Coolify deploy webhook returned HTTP $status — image is published, but auto-deploy did not fire. Marc: deploy manually via SSH recipe in .planning/phase-8-report.md."
+ echo "::error::Coolify deploy webhook returned HTTP $status — image is published, but auto-deploy did not fire. Maintainer: deploy manually on the deployment host (SSH force-pull recipe, see docs/ops/deploy.md)."
exit 1
fi
- echo "::notice::Coolify auto-deploy webhook fired at $(date -u +%FT%TZ) for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7}). If /api/version doesn't reach the new tag within ~60s, see .planning/coolify-auto-deploy-howto.md."
+ echo "::notice::Coolify auto-deploy webhook fired at $(date -u +%FT%TZ) for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7}). If /api/version doesn't reach the new tag within ~60s, re-trigger the deploy from the Coolify UI or deploy manually on the deployment host (docs/ops/deploy.md)."
echo "Coolify deploy queued — awaiting outgoing notification webhook to /api/internal/deploy-webhook for final status."
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1cec7c2fa..f5ca58944 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,25 @@
## [Unreleased]
+## [1.15.19] — 2026-06-10 — honest dose states, bounded bodies, new licence
+
+### Changed
+
+- **The project licence is now PolyForm Noncommercial 1.0.0.** HealthLog stays free to use, modify and self-host for noncommercial purposes; commercial use needs a separate arrangement. Releases up to and including v1.15.18 were published under AGPL-3.0 and remain available under that licence.
+
+### Fixed
+
+- **An open dose no longer reads as missed the moment its time passes.** A pending dose now shows as upcoming until its intake window actually closes — hours for daily schedules, days for weekly ones — so the history tab and the compliance rate stop punishing doses that are still takeable. Phantom "ad-hoc" lines for tonight's not-yet-due doses are gone too.
+- **Logging the same dose from two places no longer doubles it.** A dose acknowledged via a reminder and also logged from another client produced two live rows on the same slot, inflating the day's schedule count (4 of 4 on a two-dose day). Intake writes now converge onto the existing slot row regardless of source, the compliance rollup counts slots instead of rows — which also corrects historical days on the next recompute — and the duplicate sweep runs nightly instead of only at worker start.
+- **A weekly dose taken a few days late counts as late, not missed.** The write path only searched ±1 day around the intake time for a matching slot, so a weekly injection taken inside its multi-day catch-up window was stored as off-schedule while the slot stayed missed. The search now covers the schedule's full window reach.
+- **An intake edit can no longer park a dose on an impossible date.** Editing a dose's time now rejects future timestamps and dates before the medication existed, the picker caps at now, the dialog shows the scheduled slot next to the field and warns when the new time sits more than 48 hours away from it — a day/month typo no longer corrupts the today view silently. The edit dialog's note field is removed; it was never saved.
+- **One poisoned day no longer halts the nightly consolidation for everyone.** The daily-mean pass could collide with a row parked exactly on its canonical timestamp and abort the whole run on the first conflict, leaving every later user and metric unconsolidated night after night. Collisions now resolve deterministically and a failing day is logged, counted and skipped.
+- **Every JSON request body is now size-bounded.** Raising the upload limit for large Apple Health archives had removed the implicit ceiling on all other JSON routes; each one now enforces an explicit cap sized to its payload and answers 413 beyond it.
+
+### Added
+
+- **An operator repair script for historical intake anomalies.** `scripts/repair-intake-anomalies.ts` lists duplicate rows sharing a dose slot and intakes whose taken time sits implausibly far from their slot; with `--fix` it merges the duplicates the same way the nightly sweep does and recomputes the affected compliance days. Runbook: `docs/ops/intake-repair.md`.
+
## [1.15.18] — 2026-06-08 — a traceable medication dose history
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
index 349aca477..5cea17215 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,14 +4,14 @@ Working notes for anyone (human or otherwise) editing this repository. Skim befo
## What HealthLog is
-Self-hosted personal-health-tracking PWA: weight, blood pressure, pulse, body composition, glucose, sleep, mood, medication compliance. Withings + Apple Health sync, multi-provider AI Insights (BYOK or local), doctor-report PDF, native iOS client in public beta. Single `docker compose up`, Postgres-backed, AES-256-GCM at rest. AGPL-3.0. Current line: v1.12.x.
+Self-hosted personal-health-tracking PWA: weight, blood pressure, pulse, body composition, glucose, sleep, mood, medication compliance. Withings + Apple Health sync, multi-provider AI Insights (BYOK or local), doctor-report PDF, native iOS client in public beta. Single `docker compose up`, Postgres-backed, AES-256-GCM at rest. PolyForm Noncommercial 1.0.0 (AGPL-3.0 through v1.15.18). Current line: v1.12.x.
## Voice and privacy
Hard rules for every committed artefact — commit messages, CHANGELOG entries, PR bodies, code comments, GitHub release notes, docs site copy. Treat these as the project's public face.
- **Voice.** English. Maintainer prose: present-tense, imperative for commits, factual for prose. No marketing tone, no apology tone, no "wow what a cool feature" framing. The voice is one human writing notes for the next.
-- **No assistant / tooling references.** Do not surface AI, agent, assistant, copilot, marathon, phase, wave, round, or session vocabulary into anything that lands on GitHub. The `Co-Authored-By` trailer on commits is fine — it is git metadata and the project accepts that convention; anywhere else, no.
+- **No assistant / tooling references.** Do not surface AI, agent, assistant, copilot, marathon, phase, wave, round, or session vocabulary into anything that lands on GitHub — including commit messages and trailers. Commits carry no `Co-Authored-By` tooling trailer; they are authored by the maintainer's git identity alone.
- **No personal data.** No real names (the maintainer's, a reporter's, or anyone else's). No live health figures, no live tenant identifiers, no real-account anchors. Generalise to "a self-hoster", "a user", "an operator", "an authenticated session", etc. The internal `.planning/*` files are gitignored and exempt; everywhere else in the repo, no.
If a draft commit message or release note would violate one of the three, rewrite it before committing. The `git log` is more durable than today's task.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c2edb825d..24fb926e6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -168,4 +168,4 @@ If you find a security vulnerability, **do not open a public issue**. See [SECUR
## License
-By contributing, you agree that your contributions will be licensed under the [AGPL-3.0](LICENSE) license.
+The project is licensed under the [PolyForm Noncommercial License 1.0.0](LICENSE). By contributing, you agree that your contributions are submitted under the project license.
diff --git a/LICENSE b/LICENSE
index be3f7b28e..b2ed09ce4 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,661 +1,75 @@
- GNU AFFERO GENERAL PUBLIC LICENSE
- Version 3, 19 November 2007
-
- Copyright (C) 2007 Free Software Foundation, Inc.
- Everyone is permitted to copy and distribute verbatim copies
- of this license document, but changing it is not allowed.
-
- Preamble
-
- The GNU Affero General Public License is a free, copyleft license for
-software and other kinds of works, specifically designed to ensure
-cooperation with the community in the case of network server software.
-
- The licenses for most software and other practical works are designed
-to take away your freedom to share and change the works. By contrast,
-our General Public Licenses are intended to guarantee your freedom to
-share and change all versions of a program--to make sure it remains free
-software for all its users.
-
- When we speak of free software, we are referring to freedom, not
-price. Our General Public Licenses are designed to make sure that you
-have the freedom to distribute copies of free software (and charge for
-them if you wish), that you receive source code or can get it if you
-want it, that you can change the software or use pieces of it in new
-free programs, and that you know you can do these things.
-
- Developers that use our General Public Licenses protect your rights
-with two steps: (1) assert copyright on the software, and (2) offer
-you this License which gives you legal permission to copy, distribute
-and/or modify the software.
-
- A secondary benefit of defending all users' freedom is that
-improvements made in alternate versions of the program, if they
-receive widespread use, become available for other developers to
-incorporate. Many developers of free software are heartened and
-encouraged by the resulting cooperation. However, in the case of
-software used on network servers, this result may fail to come about.
-The GNU General Public License permits making a modified version and
-letting the public access it on a server without ever releasing its
-source code to the public.
-
- The GNU Affero General Public License is designed specifically to
-ensure that, in such cases, the modified source code becomes available
-to the community. It requires the operator of a network server to
-provide the source code of the modified version running there to the
-users of that server. Therefore, public use of a modified version, on
-a publicly accessible server, gives the public access to the source
-code of the modified version.
-
- An older license, called the Affero General Public License and
-published by Affero, was designed to accomplish similar goals. This is
-a different license, not a version of the Affero GPL, but Affero has
-released a new version of the Affero GPL which permits relicensing under
-this license.
-
- The precise terms and conditions for copying, distribution and
-modification follow.
-
- TERMS AND CONDITIONS
-
- 0. Definitions.
-
- "This License" refers to version 3 of the GNU Affero General Public License.
-
- "Copyright" also means copyright-like laws that apply to other kinds of
-works, such as semiconductor masks.
-
- "The Program" refers to any copyrightable work licensed under this
-License. Each licensee is addressed as "you". "Licensees" and
-"recipients" may be individuals or organizations.
-
- To "modify" a work means to copy from or adapt all or part of the work
-in a fashion requiring copyright permission, other than the making of an
-exact copy. The resulting work is called a "modified version" of the
-earlier work or a work "based on" the earlier work.
-
- A "covered work" means either the unmodified Program or a work based
-on the Program.
-
- To "propagate" a work means to do anything with it that, without
-permission, would make you directly or secondarily liable for
-infringement under applicable copyright law, except executing it on a
-computer or modifying a private copy. Propagation includes copying,
-distribution (with or without modification), making available to the
-public, and in some countries other activities as well.
-
- To "convey" a work means any kind of propagation that enables other
-parties to make or receive copies. Mere interaction with a user through
-a computer network, with no transfer of a copy, is not conveying.
-
- An interactive user interface displays "Appropriate Legal Notices"
-to the extent that it includes a convenient and prominently visible
-feature that (1) displays an appropriate copyright notice, and (2)
-tells the user that there is no warranty for the work (except to the
-extent that warranties are provided), that licensees may convey the
-work under this License, and how to view a copy of this License. If
-the interface presents a list of user commands or options, such as a
-menu, a prominent item in the list meets this criterion.
-
- 1. Source Code.
-
- The "source code" for a work means the preferred form of the work
-for making modifications to it. "Object code" means any non-source
-form of a work.
-
- A "Standard Interface" means an interface that either is an official
-standard defined by a recognized standards body, or, in the case of
-interfaces specified for a particular programming language, one that
-is widely used among developers working in that language.
-
- The "System Libraries" of an executable work include anything, other
-than the work as a whole, that (a) is included in the normal form of
-packaging a Major Component, but which is not part of that Major
-Component, and (b) serves only to enable use of the work with that
-Major Component, or to implement a Standard Interface for which an
-implementation is available to the public in source code form. A
-"Major Component", in this context, means a major essential component
-(kernel, window system, and so on) of the specific operating system
-(if any) on which the executable work runs, or a compiler used to
-produce the work, or an object code interpreter used to run it.
-
- The "Corresponding Source" for a work in object code form means all
-the source code needed to generate, install, and (for an executable
-work) run the object code and to modify the work, including scripts to
-control those activities. However, it does not include the work's
-System Libraries, or general-purpose tools or generally available free
-programs which are used unmodified in performing those activities but
-which are not part of the work. For example, Corresponding Source
-includes interface definition files associated with source files for
-the work, and the source code for shared libraries and dynamically
-linked subprograms that the work is specifically designed to require,
-such as by intimate data communication or control flow between those
-subprograms and other parts of the work.
-
- The Corresponding Source need not include anything that users
-can regenerate automatically from other parts of the Corresponding
-Source.
-
- The Corresponding Source for a work in source code form is that
-same work.
-
- 2. Basic Permissions.
-
- All rights granted under this License are granted for the term of
-copyright on the Program, and are irrevocable provided the stated
-conditions are met. This License explicitly affirms your unlimited
-permission to run the unmodified Program. The output from running a
-covered work is covered by this License only if the output, given its
-content, constitutes a covered work. This License acknowledges your
-rights of fair use or other equivalent, as provided by copyright law.
-
- You may make, run and propagate covered works that you do not
-convey, without conditions so long as your license otherwise remains
-in force. You may convey covered works to others for the sole purpose
-of having them make modifications exclusively for you, or provide you
-with facilities for running those works, provided that you comply with
-the terms of this License in conveying all material for which you do
-not control copyright. Those thus making or running the covered works
-for you must do so exclusively on your behalf, under your direction
-and control, on terms that prohibit them from making any copies of
-your copyrighted material outside their relationship with you.
-
- Conveying under any other circumstances is permitted solely under
-the conditions stated below. Sublicensing is not allowed; section 10
-makes it unnecessary.
-
- 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
-
- No covered work shall be deemed part of an effective technological
-measure under any applicable law fulfilling obligations under article
-11 of the WIPO copyright treaty adopted on 20 December 1996, or
-similar laws prohibiting or restricting circumvention of such
-measures.
-
- When you convey a covered work, you waive any legal power to forbid
-circumvention of technological measures to the extent such circumvention
-is effected by exercising rights under this License with respect to
-the covered work, and you disclaim any intention to limit operation or
-modification of the work as a means of enforcing, against the work's
-users, your or third parties' legal rights to forbid circumvention of
-technological measures.
-
- 4. Conveying Verbatim Copies.
-
- You may convey verbatim copies of the Program's source code as you
-receive it, in any medium, provided that you conspicuously and
-appropriately publish on each copy an appropriate copyright notice;
-keep intact all notices stating that this License and any
-non-permissive terms added in accord with section 7 apply to the code;
-keep intact all notices of the absence of any warranty; and give all
-recipients a copy of this License along with the Program.
-
- You may charge any price or no price for each copy that you convey,
-and you may offer support or warranty protection for a fee.
-
- 5. Conveying Modified Source Versions.
-
- You may convey a work based on the Program, or the modifications to
-produce it from the Program, in the form of source code under the
-terms of section 4, provided that you also meet all of these conditions:
-
- a) The work must carry prominent notices stating that you modified
- it, and giving a relevant date.
-
- b) The work must carry prominent notices stating that it is
- released under this License and any conditions added under section
- 7. This requirement modifies the requirement in section 4 to
- "keep intact all notices".
-
- c) You must license the entire work, as a whole, under this
- License to anyone who comes into possession of a copy. This
- License will therefore apply, along with any applicable section 7
- additional terms, to the whole of the work, and all its parts,
- regardless of how they are packaged. This License gives no
- permission to license the work in any other way, but it does not
- invalidate such permission if you have separately received it.
-
- d) If the work has interactive user interfaces, each must display
- Appropriate Legal Notices; however, if the Program has interactive
- interfaces that do not display Appropriate Legal Notices, your
- work need not make them do so.
-
- A compilation of a covered work with other separate and independent
-works, which are not by their nature extensions of the covered work,
-and which are not combined with it such as to form a larger program,
-in or on a volume of a storage or distribution medium, is called an
-"aggregate" if the compilation and its resulting copyright are not
-used to limit the access or legal rights of the compilation's users
-beyond what the individual works permit. Inclusion of a covered work
-in an aggregate does not cause this License to apply to the other
-parts of the aggregate.
-
- 6. Conveying Non-Source Forms.
-
- You may convey a covered work in object code form under the terms
-of sections 4 and 5, provided that you also convey the
-machine-readable Corresponding Source under the terms of this License,
-in one of these ways:
-
- a) Convey the object code in, or embodied in, a physical product
- (including a physical distribution medium), accompanied by the
- Corresponding Source fixed on a durable physical medium
- customarily used for software interchange.
-
- b) Convey the object code in, or embodied in, a physical product
- (including a physical distribution medium), accompanied by a
- written offer, valid for at least three years and valid for as
- long as you offer spare parts or customer support for that product
- model, to give anyone who possesses the object code either (1) a
- copy of the Corresponding Source for all the software in the
- product that is covered by this License, on a durable physical
- medium customarily used for software interchange, for a price no
- more than your reasonable cost of physically performing this
- conveying of source, or (2) access to copy the
- Corresponding Source from a network server at no charge.
-
- c) Convey individual copies of the object code with a copy of the
- written offer to provide the Corresponding Source. This
- alternative is allowed only occasionally and noncommercially, and
- only if you received the object code with such an offer, in accord
- with subsection 6b.
-
- d) Convey the object code by offering access from a designated
- place (gratis or for a charge), and offer equivalent access to the
- Corresponding Source in the same way through the same place at no
- further charge. You need not require recipients to copy the
- Corresponding Source along with the object code. If the place to
- copy the object code is a network server, the Corresponding Source
- may be on a different server (operated by you or a third party)
- that supports equivalent copying facilities, provided you maintain
- clear directions next to the object code saying where to find the
- Corresponding Source. Regardless of what server hosts the
- Corresponding Source, you remain obligated to ensure that it is
- available for as long as needed to satisfy these requirements.
-
- e) Convey the object code using peer-to-peer transmission, provided
- you inform other peers where the object code and Corresponding
- Source of the work are being offered to the general public at no
- charge under subsection 6d.
-
- A separable portion of the object code, whose source code is excluded
-from the Corresponding Source as a System Library, need not be
-included in conveying the object code work.
-
- A "User Product" is either (1) a "consumer product", which means any
-tangible personal property which is normally used for personal, family,
-or household purposes, or (2) anything designed or sold for incorporation
-into a dwelling. In determining whether a product is a consumer product,
-doubtful cases shall be resolved in favor of coverage. For a particular
-product received by a particular user, "normally used" refers to a
-typical or common use of that class of product, regardless of the status
-of the particular user or of the way in which the particular user
-actually uses, or expects or is expected to use, the product. A product
-is a consumer product regardless of whether the product has substantial
-commercial, industrial or non-consumer uses, unless such uses represent
-the only significant mode of use of the product.
-
- "Installation Information" for a User Product means any methods,
-procedures, authorization keys, or other information required to install
-and execute modified versions of a covered work in that User Product from
-a modified version of its Corresponding Source. The information must
-suffice to ensure that the continued functioning of the modified object
-code is in no case prevented or interfered with solely because
-modification has been made.
-
- If you convey an object code work under this section in, or with, or
-specifically for use in, a User Product, and the conveying occurs as
-part of a transaction in which the right of possession and use of the
-User Product is transferred to the recipient in perpetuity or for a
-fixed term (regardless of how the transaction is characterized), the
-Corresponding Source conveyed under this section must be accompanied
-by the Installation Information. But this requirement does not apply
-if neither you nor any third party retains the ability to install
-modified object code on the User Product (for example, the work has
-been installed in ROM).
-
- The requirement to provide Installation Information does not include a
-requirement to continue to provide support service, warranty, or updates
-for a work that has been modified or installed by the recipient, or for
-the User Product in which it has been modified or installed. Access to a
-network may be denied when the modification itself materially and
-adversely affects the operation of the network or violates the rules and
-protocols for communication across the network.
-
- Corresponding Source conveyed, and Installation Information provided,
-in accord with this section must be in a format that is publicly
-documented (and with an implementation available to the public in
-source code form), and must require no special password or key for
-unpacking, reading or copying.
-
- 7. Additional Terms.
-
- "Additional permissions" are terms that supplement the terms of this
-License by making exceptions from one or more of its conditions.
-Additional permissions that are applicable to the entire Program shall
-be treated as though they were included in this License, to the extent
-that they are valid under applicable law. If additional permissions
-apply only to part of the Program, that part may be used separately
-under those permissions, but the entire Program remains governed by
-this License without regard to the additional permissions.
-
- When you convey a copy of a covered work, you may at your option
-remove any additional permissions from that copy, or from any part of
-it. (Additional permissions may be written to require their own
-removal in certain cases when you modify the work.) You may place
-additional permissions on material, added by you to a covered work,
-for which you have or can give appropriate copyright permission.
-
- Notwithstanding any other provision of this License, for material you
-add to a covered work, you may (if authorized by the copyright holders of
-that material) supplement the terms of this License with terms:
-
- a) Disclaiming warranty or limiting liability differently from the
- terms of sections 15 and 16 of this License; or
-
- b) Requiring preservation of specified reasonable legal notices or
- author attributions in that material or in the Appropriate Legal
- Notices displayed by works containing it; or
-
- c) Prohibiting misrepresentation of the origin of that material, or
- requiring that modified versions of such material be marked in
- reasonable ways as different from the original version; or
-
- d) Limiting the use for publicity purposes of names of licensors or
- authors of the material; or
-
- e) Declining to grant rights under trademark law for use of some
- trade names, trademarks, or service marks; or
-
- f) Requiring indemnification of licensors and authors of that
- material by anyone who conveys the material (or modified versions of
- it) with contractual assumptions of liability to the recipient, for
- any liability that these contractual assumptions directly impose on
- those licensors and authors.
-
- All other non-permissive additional terms are considered "further
-restrictions" within the meaning of section 10. If the Program as you
-received it, or any part of it, contains a notice stating that it is
-governed by this License along with a term that is a further
-restriction, you may remove that term. If a license document contains
-a further restriction but permits relicensing or conveying under this
-License, you may add to a covered work material governed by the terms
-of that license document, provided that the further restriction does
-not survive such relicensing or conveying.
-
- If you add terms to a covered work in accord with this section, you
-must place, in the relevant source files, a statement of the
-additional terms that apply to those files, or a notice indicating
-where to find the applicable terms.
-
- Additional terms, permissive or non-permissive, may be stated in the
-form of a separately written license, or stated as exceptions;
-the above requirements apply either way.
-
- 8. Termination.
-
- You may not propagate or modify a covered work except as expressly
-provided under this License. Any attempt otherwise to propagate or
-modify it is void, and will automatically terminate your rights under
-this License (including any patent licenses granted under the third
-paragraph of section 11).
-
- However, if you cease all violation of this License, then your
-license from a particular copyright holder is reinstated (a)
-provisionally, unless and until the copyright holder explicitly and
-finally terminates your license, and (b) permanently, if the copyright
-holder fails to notify you of the violation by some reasonable means
-prior to 60 days after the cessation.
-
- Moreover, your license from a particular copyright holder is
-reinstated permanently if the copyright holder notifies you of the
-violation by some reasonable means, this is the first time you have
-received notice of violation of this License (for any work) from that
-copyright holder, and you cure the violation prior to 30 days after
-your receipt of the notice.
-
- Termination of your rights under this section does not terminate the
-licenses of parties who have received copies or rights from you under
-this License. If your rights have been terminated and not permanently
-reinstated, you do not qualify to receive new licenses for the same
-material under section 10.
-
- 9. Acceptance Not Required for Having Copies.
-
- You are not required to accept this License in order to receive or
-run a copy of the Program. Ancillary propagation of a covered work
-occurring solely as a consequence of using peer-to-peer transmission
-to receive a copy likewise does not require acceptance. However,
-nothing other than this License grants you permission to propagate or
-modify any covered work. These actions infringe copyright if you do
-not accept this License. Therefore, by modifying or propagating a
-covered work, you indicate your acceptance of this License to do so.
-
- 10. Automatic Licensing of Downstream Recipients.
-
- Each time you convey a covered work, the recipient automatically
-receives a license from the original licensors, to run, modify and
-propagate that work, subject to this License. You are not responsible
-for enforcing compliance by third parties with this License.
-
- An "entity transaction" is a transaction transferring control of an
-organization, or substantially all assets of one, or subdividing an
-organization, or merging organizations. If propagation of a covered
-work results from an entity transaction, each party to that
-transaction who receives a copy of the work also receives whatever
-licenses to the work the party's predecessor in interest had or could
-give under the previous paragraph, plus a right to possession of the
-Corresponding Source of the work from the predecessor in interest, if
-the predecessor has it or can get it with reasonable efforts.
-
- You may not impose any further restrictions on the exercise of the
-rights granted or affirmed under this License. For example, you may
-not impose a license fee, royalty, or other charge for exercise of
-rights granted under this License, and you may not initiate litigation
-(including a cross-claim or counterclaim in a lawsuit) alleging that
-any patent claim is infringed by making, using, selling, offering for
-sale, or importing the Program or any portion of it.
-
- 11. Patents.
-
- A "contributor" is a copyright holder who authorizes use under this
-License of the Program or a work on which the Program is based. The
-work thus licensed is called the contributor's "contributor version".
-
- A contributor's "essential patent claims" are all patent claims
-owned or controlled by the contributor, whether already acquired or
-hereafter acquired, that would be infringed by some manner, permitted
-by this License, of making, using, or selling its contributor version,
-but do not include claims that would be infringed only as a
-consequence of further modification of the contributor version. For
-purposes of this definition, "control" includes the right to grant
-patent sublicenses in a manner consistent with the requirements of
-this License.
-
- Each contributor grants you a non-exclusive, worldwide, royalty-free
-patent license under the contributor's essential patent claims, to
-make, use, sell, offer for sale, import and otherwise run, modify and
-propagate the contents of its contributor version.
-
- In the following three paragraphs, a "patent license" is any express
-agreement or commitment, however denominated, not to enforce a patent
-(such as an express permission to practice a patent or covenant not to
-sue for patent infringement). To "grant" such a patent license to a
-party means to make such an agreement or commitment not to enforce a
-patent against the party.
-
- If you convey a covered work, knowingly relying on a patent license,
-and the Corresponding Source of the work is not available for anyone
-to copy, free of charge and under the terms of this License, through a
-publicly available network server or other readily accessible means,
-then you must either (1) cause the Corresponding Source to be so
-available, or (2) arrange to deprive yourself of the benefit of the
-patent license for this particular work, or (3) arrange, in a manner
-consistent with the requirements of this License, to extend the patent
-license to downstream recipients. "Knowingly relying" means you have
-actual knowledge that, but for the patent license, your conveying the
-covered work in a country, or your recipient's use of the covered work
-in a country, would infringe one or more identifiable patents in that
-country that you have reason to believe are valid.
-
- If, pursuant to or in connection with a single transaction or
-arrangement, you convey, or propagate by procuring conveyance of, a
-covered work, and grant a patent license to some of the parties
-receiving the covered work authorizing them to use, propagate, modify
-or convey a specific copy of the covered work, then the patent license
-you grant is automatically extended to all recipients of the covered
-work and works based on it.
-
- A patent license is "discriminatory" if it does not include within
-the scope of its coverage, prohibits the exercise of, or is
-conditioned on the non-exercise of one or more of the rights that are
-specifically granted under this License. You may not convey a covered
-work if you are a party to an arrangement with a third party that is
-in the business of distributing software, under which you make payment
-to the third party based on the extent of your activity of conveying
-the work, and under which the third party grants, to any of the
-parties who would receive the covered work from you, a discriminatory
-patent license (a) in connection with copies of the covered work
-conveyed by you (or copies made from those copies), or (b) primarily
-for and in connection with specific products or compilations that
-contain the covered work, unless you entered into that arrangement,
-or that patent license was granted, prior to 28 March 2007.
-
- Nothing in this License shall be construed as excluding or limiting
-any implied license or other defenses to infringement that may
-otherwise be available to you under applicable patent law.
-
- 12. No Surrender of Others' Freedom.
-
- If conditions are imposed on you (whether by court order, agreement or
-otherwise) that contradict the conditions of this License, they do not
-excuse you from the conditions of this License. If you cannot convey a
-covered work so as to satisfy simultaneously your obligations under this
-License and any other pertinent obligations, then as a consequence you may
-not convey it at all. For example, if you agree to terms that obligate you
-to collect a royalty for further conveying from those to whom you convey
-the Program, the only way you could satisfy both those terms and this
-License would be to refrain entirely from conveying the Program.
-
- 13. Remote Network Interaction; Use with the GNU General Public License.
-
- Notwithstanding any other provision of this License, if you modify the
-Program, your modified version must prominently offer all users
-interacting with it remotely through a computer network (if your version
-supports such interaction) an opportunity to receive the Corresponding
-Source of your version by providing access to the Corresponding Source
-from a network server at no charge, through some standard or customary
-means of facilitating copying of software. This Corresponding Source
-shall include the Corresponding Source for any work covered by version 3
-of the GNU General Public License that is incorporated pursuant to the
-following paragraph.
-
- Notwithstanding any other provision of this License, you have
-permission to link or combine any covered work with a work licensed
-under version 3 of the GNU General Public License into a single
-combined work, and to convey the resulting work. The terms of this
-License will continue to apply to the part which is the covered work,
-but the work with which it is combined will remain governed by version
-3 of the GNU General Public License.
-
- 14. Revised Versions of this License.
-
- The Free Software Foundation may publish revised and/or new versions of
-the GNU Affero General Public License from time to time. Such new versions
-will be similar in spirit to the present version, but may differ in detail to
-address new problems or concerns.
-
- Each version is given a distinguishing version number. If the
-Program specifies that a certain numbered version of the GNU Affero General
-Public License "or any later version" applies to it, you have the
-option of following the terms and conditions either of that numbered
-version or of any later version published by the Free Software
-Foundation. If the Program does not specify a version number of the
-GNU Affero General Public License, you may choose any version ever published
-by the Free Software Foundation.
-
- If the Program specifies that a proxy can decide which future
-versions of the GNU Affero General Public License can be used, that proxy's
-public statement of acceptance of a version permanently authorizes you
-to choose that version for the Program.
-
- Later license versions may give you additional or different
-permissions. However, no additional obligations are imposed on any
-author or copyright holder as a result of your choosing to follow a
-later version.
-
- 15. Disclaimer of Warranty.
-
- THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
-APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
-HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
-OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
-THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
-PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
-IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
-ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
-
- 16. Limitation of Liability.
-
- IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
-WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
-THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
-GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
-USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
-DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
-PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
-EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
-SUCH DAMAGES.
-
- 17. Interpretation of Sections 15 and 16.
-
- If the disclaimer of warranty and limitation of liability provided
-above cannot be given local legal effect according to their terms,
-reviewing courts shall apply local law that most closely approximates
-an absolute waiver of all civil liability in connection with the
-Program, unless a warranty or assumption of liability accompanies a
-copy of the Program in return for a fee.
-
- END OF TERMS AND CONDITIONS
-
- How to Apply These Terms to Your New Programs
-
- If you develop a new program, and you want it to be of the greatest
-possible use to the public, the best way to achieve this is to make it
-free software which everyone can redistribute and change under these terms.
-
- To do so, attach the following notices to the program. It is safest
-to attach them to the start of each source file to most effectively
-state the exclusion of warranty; and each file should have at least
-the "copyright" line and a pointer to where the full notice is found.
-
-
- Copyright (C)
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see .
-
-Also add information on how to contact you by electronic and paper mail.
-
- If your software can interact with users remotely through a computer
-network, you should also make sure that it provides a way for users to
-get its source. For example, if your program is a web application, its
-interface could display a "Source" link that leads users to an archive
-of the code. There are many ways you could offer source, and different
-solutions will be better for different programs; see section 13 for the
-specific requirements.
-
- You should also get your employer (if you work as a programmer) or school,
-if any, to sign a "copyright disclaimer" for the program, if necessary.
-For more information on this, and how to apply and follow the GNU AGPL, see
-.
+Required Notice: Copyright (c) 2026 the HealthLog maintainers
+
+# PolyForm Noncommercial License 1.0.0
+
+
+
+## Acceptance
+
+In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
+
+## Copyright License
+
+The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
+
+## Distribution License
+
+The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
+
+## Notices
+
+You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
+
+> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
+
+## Changes and New Works License
+
+The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
+
+## Patent License
+
+The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
+
+## Noncommercial Purposes
+
+Any noncommercial purpose is a permitted purpose.
+
+## Personal Uses
+
+Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
+
+## Noncommercial Organizations
+
+Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
+
+## Fair Use
+
+You may have "fair use" rights for the software under the law. These terms do not limit them.
+
+## No Other Rights
+
+These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
+
+## Patent Defense
+
+If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
+
+## Violations
+
+The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
+
+## No Liability
+
+***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
+
+## Definitions
+
+The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
+
+**You** refers to the individual or entity agreeing to these terms.
+
+**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
+
+**Your licenses** are all the licenses granted to you for the software under these terms.
+
+**Use** means anything you do with the software requiring one of your licenses.
diff --git a/README.md b/README.md
index 4983f2736..5169a335b 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
-
+
@@ -60,7 +60,7 @@
## What it is
-HealthLog is a self-hosted personal health tracker that runs from a single `docker compose up`. It covers the metrics most people actually log -- weight, blood pressure, pulse, body composition, blood glucose, sleep, mood, and medication compliance -- and brings them together in one dashboard with reference ranges from ESH 2023, ADA 2024, and NICE NG115. Withings, WHOOP, and Google Health/Fitbit devices sync automatically over OAuth2; an `export.zip` import folds your full Apple Health history into the same timeline; a native SwiftUI iOS client (public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa)) streams HealthKit live; multi-provider AI insights (BYOK or local) explain what the numbers mean; a doctor-report PDF generates client-side. EN/DE end-to-end. AGPL-3.0.
+HealthLog is a self-hosted personal health tracker that runs from a single `docker compose up`. It covers the metrics most people actually log -- weight, blood pressure, pulse, body composition, blood glucose, sleep, mood, and medication compliance -- and brings them together in one dashboard with reference ranges from ESH 2023, ADA 2024, and NICE NG115. Withings, WHOOP, and Google Health/Fitbit devices sync automatically over OAuth2; an `export.zip` import folds your full Apple Health history into the same timeline; a native SwiftUI iOS client (public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa)) streams HealthKit live; multi-provider AI insights (BYOK or local) explain what the numbers mean; a doctor-report PDF generates client-side. EN/DE end-to-end. PolyForm Noncommercial 1.0.0.
> **Status**: active. New releases roughly weekly -- see [CHANGELOG](CHANGELOG.md). Current line: v1.12 — a Google Health/Fitbit connection for Fitbit and Pixel wearables (experimental, see [Integrations](#integrations)) and a rebuilt mood log (five-face capture, a structured tag catalog, and rated daily-life factors), on top of the v1.11 mood-relations and sleep-depth insights, a read-only FHIR R4 API with shareable clinician records, the v1.10 derived-metrics tier (fitness-age, vascular-age delta, HRV balance, sleep score, readiness and recovery scores, an early-strain flag), and the v1.7 health-record export (PDF + FHIR R4). The native iOS client is public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa).
@@ -81,7 +81,7 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
| | HealthLog | Withings web | Apple Health | Oura web | WHOOP web | Fitbit web | Generic CSV |
| -------------------------- | -------------------- | --------------- | ------------- | ----------- | ----------- | ------------ | ----------- |
| Self-hosted | Yes | No | No | No | No | No | Yes |
-| Open source | AGPL-3.0 | No | No | No | No | No | n/a |
+| Source-available | PolyForm-NC-1.0.0 | No | No | No | No | No | n/a |
| Withings device sync | Yes (OAuth2) | Yes (native) | Via shortcut | No | No | No | No |
| WHOOP device sync | Yes (OAuth2, BYO-keys)| No | No | No | Native | No | No |
| Google Health / Fitbit sync| Yes (experimental) | No | No | No | No | Native | No |
@@ -102,7 +102,7 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
The wrist-band ecosystems are good at what their hardware measures, and HealthLog does not try to be a ring or a strap. It does not ship a sensor, and it does not claim to match one. What it does is take the readings those devices already collect — plus the ones they don't hold, like cuff blood pressure, finger-stick or CGM glucose, and medication intake — and keep them on a server you run.
-- **You own the data and the host.** No vendor account, no cloud round-trip, no subscription tier gating a metric you already recorded. AGPL-3.0, single `docker compose up`, encrypted at rest, on your NAS / homelab / VPS.
+- **You own the data and the host.** No vendor account, no cloud round-trip, no subscription tier gating a metric you already recorded. PolyForm Noncommercial 1.0.0, single `docker compose up`, encrypted at rest, on your NAS / homelab / VPS.
- **It aggregates across providers instead of locking you to one.** Withings and WHOOP devices sync over OAuth2, a Google Health/Fitbit connection pulls Fitbit and Pixel data (experimental), an Apple Health `export.zip` folds your full history in, and the native iOS client streams HealthKit live — so a Withings scale, a WHOOP strap, an Apple Watch, and a glucometer land on one timeline instead of four apps. When two providers report the same metric, a per-metric source priority decides which one is canonical, so a dedicated scale outranks a wrist estimate and cumulative metrics like steps are never double-counted (see [Source priority](#source-priority)).
- **It carries context the wrist bands don't.** Blood pressure, blood glucose, body composition, and cadence-aware medication compliance sit next to the wearable signals, so a trend reads against the whole picture rather than activity and sleep alone.
- **The derived metrics are transparent, not a black box.** HealthLog computes a fitness-age and vascular-age delta, an HRV-balance and sleep-score band, a daily readiness and recovery score, and a coincident-deviation early-strain flag — each from your own measurements, each citing the inputs and the published method behind it, each degrading honestly to "not enough data" rather than fabricating a number. These are descriptive wellness framings, not clinical or training-grade assessments, and a score is only stored once its minimum inputs are present.
@@ -653,7 +653,7 @@ Spezi exists because medical-device-grade software needs medical-device-grade pr
### Status
-`v0.6.1.x` ships almost daily; the [iOS repo CHANGELOG](https://github.com/MBombeck/healthlog-iOS/blob/main/CHANGELOG.md) and tag list track what landed in each build. German-primary UI with English secondary. iOS 18+ minimum (iOS 26+ for the on-device Coach). AGPL-3.0, same as this repo.
+`v0.6.1.x` ships almost daily; the [iOS repo CHANGELOG](https://github.com/MBombeck/healthlog-iOS/blob/main/CHANGELOG.md) and tag list track what landed in each build. German-primary UI with English secondary. iOS 18+ minimum (iOS 26+ for the on-device Coach). PolyForm Noncommercial 1.0.0, same as this repo.
TestFlight beta ·
@@ -676,7 +676,9 @@ Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines
## License
-HealthLog is licensed under the [GNU Affero General Public License v3.0](LICENSE).
+HealthLog is licensed under the [PolyForm Noncommercial License 1.0.0](LICENSE). It is free to use, self-host, and modify for noncommercial purposes; commercial use requires a separate agreement with the maintainers.
+
+Releases up to and including v1.15.18 were published under the GNU Affero General Public License v3.0 and remain available under that license.
---
diff --git a/docker-compose.yml b/docker-compose.yml
index 5cc35e198..1f382c67a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -65,6 +65,13 @@ services:
# container (the `environment:` block is a whitelist).
WHOOP_WEBHOOK_SECRET: "${WHOOP_WEBHOOK_SECRET:-}"
WHOOP_REDIRECT_URI: "${WHOOP_REDIRECT_URI:-}"
+ # Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
+ # POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
+ # deployment-dashboard URL (e.g. the Coolify UI) linked in
+ # deploy-failure notifications. Listed here so operator values in
+ # .env reach the container (the `environment:` block is a whitelist).
+ DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}"
+ DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}"
# TLS leaf SPKI-change alarm baseline (optional). The native client
# pins the served TLS leaf; this is the known-good pin set the monitor
# alarms against on a rotation. Comma-separated for the dual-pin
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index fe9e190fc..85602e835 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.15.18
+ version: 1.15.19
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -15,14 +15,14 @@ info:
Background-job-authored payloads (backups, admin tools) emit `Z`. Native iOS clients should always parse via a
Foundation ISO-8601 formatter with the `withInternetDateTime` + `withTimeZone` options enabled.
license:
- name: AGPL-3.0
+ name: PolyForm-Noncommercial-1.0.0
url: https://github.com/MBombeck/HealthLog/blob/main/LICENSE
contact:
name: HealthLog
url: https://healthlog.dev
servers:
- - url: https://healthlog.bombeck.io
- description: Production
+ - url: https://healthlog.example.com
+ description: Your self-hosted instance
- url: http://localhost:3000
description: Local dev
tags:
diff --git a/docs/apple-store-connect-checklist.md b/docs/apple-store-connect-checklist.md
index 323a419b6..88806d1d1 100644
--- a/docs/apple-store-connect-checklist.md
+++ b/docs/apple-store-connect-checklist.md
@@ -1,7 +1,7 @@
---
file: docs/apple-store-connect-checklist.md
purpose: Step-by-step ASC submission checklist for HealthLog iOS — covering medical-device declaration, privacy nutrition labels, and ASC metadata
-audience: Marc (the maintainer) before submitting to App Store
+audience: the maintainer, before submitting to App Store
estimated_read_time: 15 min
last_updated: 2026-05-15
---
@@ -10,7 +10,7 @@ last_updated: 2026-05-15
## TL;DR
-Three submission gates that need Marc's hand at ASC:
+Three submission gates that need the maintainer's hand at ASC:
1. Regulated-Medical-Device declaration (Guideline 1.4 + 5.1.1(ix))
2. Privacy Nutrition Labels matching the iOS Privacy Manifest (Guideline 5.1.2)
3. Standard metadata (name, description, screenshots, categories, age rating)
@@ -29,7 +29,7 @@ Plus pre-submit final checks.
**What to check**:
- [ ] Check "No" on the medical-device toggle
- [ ] Add the description above to the medical-device-justification field if Apple shows one
-- [ ] Cross-reference the in-app `/privacy` page (https://healthlog.bombeck.io/privacy) §7 MDR-boundary
+- [ ] Cross-reference the in-app `/privacy` page (https:///privacy) §7 MDR-boundary
**Why this matters**: Apple's first-submission rejection rate for health apps is ~40%. The most common reason is unclear medical-device classification. The above wording mirrors the in-app disclaimer + the per-feature gating; consistency between the form and the app is what Apple looks for.
@@ -37,7 +37,7 @@ Plus pre-submit final checks.
**Where**: App Store Connect → App Privacy → "Edit" the privacy choices.
-**Cross-reference**: every data category here MUST match what the iOS Privacy Manifest (in the iOS repo) declares. iOS-Claude's v0.3.0 release extended the manifest to: Email + OtherUserContent + SensitiveInfo + DeviceID + UserID + AccountManagement-purpose.
+**Cross-reference**: every data category here MUST match what the iOS Privacy Manifest (in the iOS repo) declares. The iOS client's v0.3.0 release extended the manifest to: Email + OtherUserContent + SensitiveInfo + DeviceID + UserID + AccountManagement-purpose.
### Data categories to declare
@@ -86,7 +86,7 @@ For each category below: **Linked to user, used for App Functionality + Account
- Location (Precise or Coarse) — workout GPS routes are opt-in per-workout; if the iOS app implements them, declare; otherwise omit
- Purchases
- Financial Info
-- Diagnostics, Crash Data, Performance Data, Other Diagnostic Data — if iOS uses any analytics SDK, declare; otherwise omit. Verify with iOS-Claude.
+- Diagnostics, Crash Data, Performance Data, Other Diagnostic Data — if iOS uses any analytics SDK, declare; otherwise omit. Verify against the iOS client.
- Advertising Data
- Other Usage Data
@@ -101,7 +101,7 @@ For each category below: **Linked to user, used for App Functionality + Account
- [ ] Subtitle: 30-char tagline (suggestion: "Personal health-data log + Coach")
- [ ] Primary category: Health & Fitness
- [ ] Secondary category: Medical (if Apple accepts non-medical-device classification for it)
-- [ ] Content rights: confirm Marc owns the app rights
+- [ ] Content rights: confirm the maintainer owns the app rights
- [ ] Age rating: 17+ (because Sensitive Health Info content)
### Description
@@ -114,36 +114,36 @@ For each category below: **Linked to user, used for App Functionality + Account
- [ ] iPad screenshots if supporting iPad.
### URLs
-- [ ] Marketing URL: https://healthlog.bombeck.io (or marketing site once landing repo's v1.4.25 deployed)
-- [ ] Privacy Policy URL: **https://healthlog.bombeck.io/privacy** (ships with v1.4.25.1 hotfix)
+- [ ] Marketing URL: https:// (or the marketing site once the landing repo's v1.4.25 is deployed)
+- [ ] Privacy Policy URL: **https:///privacy** (ships with v1.4.25.1 hotfix)
- [ ] Support URL: https://github.com/MBombeck/HealthLog/issues
### App Review Information
-- [ ] Demo account credentials: Marc creates a read-only demo account on `demo.healthlog.dev` and provides credentials in the demo account fields
+- [ ] Demo account credentials: the maintainer creates a read-only demo account on `demo.healthlog.dev` and provides credentials in the demo account fields
- [ ] Review notes: explain MDR boundary + AI Coach safety contract + Research Mode gating; reference `/privacy` §7 MDR-boundary. Apple reviewers DO read this and DO check the in-app behavior matches the claim.
- [ ] Sign-in required: yes (provide demo credentials)
### Pricing + Availability
- [ ] Free
-- [ ] Availability: select countries (Marc-decision — recommend EU + US + Canada + Australia + Japan for first launch)
+- [ ] Availability: select countries (maintainer decision — recommend EU + US + Canada + Australia + Japan for first launch)
## Section 4 — Pre-Submit Final Check
Run through this list:
-- [ ] Privacy Policy URL responds 200 (curl https://healthlog.bombeck.io/privacy)
+- [ ] Privacy Policy URL responds 200 (curl https:///privacy)
- [ ] iOS Privacy Manifest categories match the App Privacy declarations in §2
- [ ] Demo account works on the demo server
- [ ] iOS app's TestFlight build is on the latest commit
-- [ ] Marc has the APNs .p8 backed up to 1Password or similar (see `.planning/v15-apns-key-handling.md`)
-- [ ] Marc's Apple Developer Program subscription is active
+- [ ] The maintainer has the APNs .p8 backed up to a password manager
+- [ ] The maintainer's Apple Developer Program subscription is active
## Section 5 — Common First-Submission Rejection Reasons (Apple Health-App-Specific)
- Guideline 1.4.1: "Medical claims" — wording in app or screenshots implies diagnosis/treatment → fix: tone down wording, lean on the MDR-boundary disclaimer
-- Guideline 5.1.1(v): "Account Deletion missing" — iOS-Claude added this in v0.3.0
-- Guideline 5.1.2(i): "Third-Party AI consent" — DEFERRED to v0.3.1 per iOS-Claude release notes; resubmission may flag
-- Guideline 5.1.2: "Privacy Manifest mismatch" — see §2; iOS-Claude needs to ensure the manifest matches
+- Guideline 5.1.1(v): "Account Deletion missing" — the iOS client added this in v0.3.0
+- Guideline 5.1.2(i): "Third-Party AI consent" — DEFERRED to v0.3.1 per the iOS release notes; resubmission may flag
+- Guideline 5.1.2: "Privacy Manifest mismatch" — see §2; the iOS release process needs to ensure the manifest matches
## Section 6 — Post-Submit / If Rejected
diff --git a/docs/ops/intake-repair.md b/docs/ops/intake-repair.md
new file mode 100644
index 000000000..c7c9e7099
--- /dev/null
+++ b/docs/ops/intake-repair.md
@@ -0,0 +1,79 @@
+# Medication intake repair
+
+`scripts/repair-intake-anomalies.ts` repairs two known historic defects in
+the medication intake ledger (`medication_intake_events`):
+
+1. **Duplicate dose-slot rows.** More than one live row on the same exact
+ `(user, medication, scheduled_for)` tuple — cross-source duplicates the
+ pre-v1.15.19 write path could mint (e.g. a pending REMINDER row plus a
+ taken API row for the same slot). The duplicate inflates the per-day
+ scheduled count and paints a phantom entry in the history view.
+2. **Implausible `taken_at`.** Live rows whose `taken_at` lands more than
+ 7 days before `scheduled_for` or more than 1 day in the future —
+ typically the residue of a mis-attributed edit before the v1.15.19
+ `taken_at` validation landed.
+
+## When to use it
+
+- After upgrading to v1.15.19+, when a user reports a compliance rate above
+ 100 %, duplicate entries in the medication history, or a dose shown as
+ taken days away from its slot.
+- The compliance rollup counts DISTINCT slots since v1.15.19, so the
+ percentages self-correct on recompute even without running this script.
+ Running it additionally clears the phantom rows from the history view.
+- The `intake-slot-dedup` worker already collapses canonical-slot
+ duplicates at boot and on a daily cron; this script is the manual,
+ immediately-observable fallback and the only path that surfaces the
+ implausible-`taken_at` rows.
+
+## Run a dry-run first
+
+Always. The default mode only reports and never mutates:
+
+```bash
+# inside the app container (or any checkout with DATABASE_URL set)
+pnpm dlx tsx scripts/repair-intake-anomalies.ts
+
+# scoped to one account
+pnpm dlx tsx scripts/repair-intake-anomalies.ts --user
+```
+
+Use `pnpm dlx tsx`, not bare `pnpm tsx` — the production standalone image
+strips `tsx`. `DATABASE_URL` must point at the target database.
+
+The dry-run prints every duplicate group (which row would be kept, which
+would be tombstoned) and a table of implausible rows (id, medication,
+`scheduledFor`, `takenAt`, source, timestamps).
+
+## Applying the fix
+
+```bash
+pnpm dlx tsx scripts/repair-intake-anomalies.ts --fix
+```
+
+`--fix` does three things:
+
+- **Duplicate groups** are collapsed to one winner per slot. Precedence:
+ an actioned row (taken or skipped) beats a pending one; between two
+ taken rows the earlier `taken_at` wins; ties break deterministically on
+ `created_at`, then `id`. Losers are soft-deleted (`deleted_at` set,
+ `sync_version` incremented) — the same tombstone shape the delete route
+ writes, so connected clients drop the rows on their next delta-sync.
+ Nothing is ever hard-deleted.
+- **Implausible `taken_at` rows are NOT touched.** The recorded intent is
+ unknowable, so the operator (or the user) corrects or deletes them in
+ the medication history tab. To soft-delete them wholesale instead, opt
+ in explicitly:
+
+ ```bash
+ pnpm dlx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
+ ```
+
+ `--tombstone-implausible` is refused without `--fix`.
+- **Affected compliance rollups are recomputed** for every touched
+ `(user, medication, day)` through the shared rollup helper, so the
+ scheduled/taken counts and the rate self-correct immediately.
+
+The script is idempotent: a second `--fix` run finds zero duplicate groups
+and changes nothing. Exit code is 0 on success (including a clean
+zero-findings run), non-zero on bad arguments or a fatal error.
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 74782b5a5..a1d07e8eb 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -13,8 +13,8 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
- // v1.4.41 — ignore Claude Code session worktrees so a mid-marathon
- // worktree on an older commit can't poison `pnpm lint` locally.
+ // v1.4.41 — ignore local tool worktrees so a worktree on an older
+ // commit can't poison `pnpm lint` locally.
// CI never sees this path; the rule is for the dev environment.
".claude/**",
// The project-local ESLint rule plugin is CommonJS (require/module
@@ -22,11 +22,11 @@ const eslintConfig = defineConfig([
// is not linted by the app's TypeScript ruleset.
"eslint-plugins/**",
]),
- // v1.4.41 W-PROCESS-DOCS — custom rule that flags any bare-array
+ // v1.4.41 — custom rule that flags any bare-array
// `queryKey: [ … ]` / `mutationKey: [ … ]` declaration inside the
- // files the queryKeys factory currently guards. Promotes the
- // test-guard substitute from v1.4.40 W-RSC to a real ESLint rule
- // so IDE + CI both fail fast on a factory bypass. See
+ // files the queryKeys factory currently guards. Promotes the earlier
+ // v1.4.40 test-guard substitute to a real ESLint rule so IDE + CI
+ // both fail fast on a factory bypass. See
// `eslint-plugins/healthlog/queryKey-factory.js` for the
// whitelist + rationale.
{
diff --git a/messages/de.json b/messages/de.json
index 8ab6d3dbb..430d15ebc 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -1323,8 +1323,9 @@
"edit": {
"dialogTitle": "Einnahme bearbeiten",
"takenAtLabel": "Eingenommen um",
+ "scheduledForHint": "Geplant: {dateTime}",
+ "farFromScheduledWarning": "Liegt weit vom geplanten Zeitpunkt entfernt – Datum prüfen.",
"skippedLabel": "Übersprungen",
- "noteLabel": "Notiz",
"save": "Speichern",
"cancel": "Abbrechen",
"savedToast": "Einnahme aktualisiert",
diff --git a/messages/en.json b/messages/en.json
index 58d054157..aac9080a9 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1323,8 +1323,9 @@
"edit": {
"dialogTitle": "Edit intake",
"takenAtLabel": "Taken at",
+ "scheduledForHint": "Scheduled: {dateTime}",
+ "farFromScheduledWarning": "This is far from the scheduled time — check the date.",
"skippedLabel": "Skipped",
- "noteLabel": "Note",
"save": "Save",
"cancel": "Cancel",
"savedToast": "Intake updated",
diff --git a/messages/es.json b/messages/es.json
index a07cce72b..b37cd76c0 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -1323,8 +1323,9 @@
"edit": {
"dialogTitle": "Editar toma",
"takenAtLabel": "Tomada a las",
+ "scheduledForHint": "Programada: {dateTime}",
+ "farFromScheduledWarning": "Está muy lejos de la hora programada; comprueba la fecha.",
"skippedLabel": "Omitida",
- "noteLabel": "Nota",
"save": "Guardar",
"cancel": "Cancelar",
"savedToast": "Toma actualizada",
diff --git a/messages/fr.json b/messages/fr.json
index 75f0bb202..3399e0725 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -1323,8 +1323,9 @@
"edit": {
"dialogTitle": "Modifier la prise",
"takenAtLabel": "Prise à",
+ "scheduledForHint": "Prévue : {dateTime}",
+ "farFromScheduledWarning": "Très éloignée de l’heure prévue — vérifiez la date.",
"skippedLabel": "Sautée",
- "noteLabel": "Note",
"save": "Enregistrer",
"cancel": "Annuler",
"savedToast": "Prise mise à jour",
diff --git a/messages/it.json b/messages/it.json
index 8d6623f95..b56733170 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -1323,8 +1323,9 @@
"edit": {
"dialogTitle": "Modifica assunzione",
"takenAtLabel": "Assunta alle",
+ "scheduledForHint": "Programmata: {dateTime}",
+ "farFromScheduledWarning": "Molto distante dall’orario programmato — controlla la data.",
"skippedLabel": "Saltata",
- "noteLabel": "Nota",
"save": "Salva",
"cancel": "Annulla",
"savedToast": "Assunzione aggiornata",
diff --git a/messages/pl.json b/messages/pl.json
index c3266cdd8..71c5b8d23 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -1323,8 +1323,9 @@
"edit": {
"dialogTitle": "Edytuj przyjęcie",
"takenAtLabel": "Przyjęto o",
+ "scheduledForHint": "Zaplanowano: {dateTime}",
+ "farFromScheduledWarning": "Znacznie odbiega od zaplanowanego terminu — sprawdź datę.",
"skippedLabel": "Pominięto",
- "noteLabel": "Notatka",
"save": "Zapisz",
"cancel": "Anuluj",
"savedToast": "Przyjęcie zaktualizowane",
diff --git a/package.json b/package.json
index 7ccdfc4a0..fd98f0e4c 100644
--- a/package.json
+++ b/package.json
@@ -1,8 +1,8 @@
{
"name": "healthlog",
- "version": "1.15.18",
+ "version": "1.15.19",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
- "license": "AGPL-3.0-only",
+ "license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
"repository": {
"type": "git",
diff --git a/scripts/README.md b/scripts/README.md
index 5fcadab54..7834722dc 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -18,6 +18,7 @@ Use `pnpm dlx tsx`, not bare `pnpm tsx`. The production standalone image strips
- **`assert-deploy.ts`** — post-deploy `/api/version` assertion.
- **`rotate-encryption-key.ts`** — AES-256-GCM key rotation; see `docs/ops/encryption-key-rotation.md`.
- **`restore-backup.ts`** — restore an off-host AES-GCM backup from an S3-compatible bucket.
+- **`repair-intake-anomalies.ts`** — repair historic medication-ledger defects (duplicate dose-slot rows, implausible `taken_at`); dry-run by default, see `docs/ops/intake-repair.md`.
- **`backfill-rollups.ts`**, **`drain-per-sample-cumulative.ts`**, **`backfill-mood-note-column.ts`** — historical data backfills (note: the boot-time `rollup-full-backfill` queue handles new accounts automatically; the CLI is the manual fallback).
- **`seed-demo.ts`** + **`seed-demo-v15.sql`** — populate a demo tenant.
- **`test-notifications.ts`** — exercise the notification dispatcher + reminder check.
diff --git a/scripts/assert-deploy.ts b/scripts/assert-deploy.ts
index ea1e677d7..554d450ee 100644
--- a/scripts/assert-deploy.ts
+++ b/scripts/assert-deploy.ts
@@ -13,7 +13,11 @@
* Run it after every deploy — the queued-deploy status is not the source
* of truth, the served version is:
*
- * pnpm dlx tsx scripts/assert-deploy.ts
+ * HEALTHLOG_PROD_URL=https://healthlog.example.com \
+ * pnpm dlx tsx scripts/assert-deploy.ts
+ *
+ * The prod target's base URL comes from HEALTHLOG_PROD_URL (required
+ * unless --only=demo).
*
* Examples:
*
@@ -36,8 +40,15 @@ interface Target {
url: string;
}
-const TARGETS: Record = {
- prod: { name: "prod", url: "https://healthlog.bombeck.io/api/version" },
+// The production base URL is instance-specific — there is no meaningful
+// default for a self-hosted app. Set HEALTHLOG_PROD_URL (e.g.
+// `https://healthlog.example.com`) before asserting the prod target.
+const PROD_BASE_URL = process.env.HEALTHLOG_PROD_URL?.replace(/\/+$/, "");
+
+const TARGETS: Record = {
+ prod: PROD_BASE_URL
+ ? { name: "prod", url: `${PROD_BASE_URL}/api/version` }
+ : undefined,
demo: { name: "demo", url: "https://demo.healthlog.dev/api/version" },
};
@@ -140,14 +151,26 @@ async function main(): Promise {
let selected: Target[];
if (only) {
+ if (!(only in TARGETS)) {
+ console.error(`unknown --only target: ${only} (use prod or demo)`);
+ process.exit(2);
+ }
const picked = TARGETS[only];
if (!picked) {
- console.error(`unknown --only target: ${only} (use prod or demo)`);
+ console.error(
+ `target "${only}" needs HEALTHLOG_PROD_URL set to the instance base URL (e.g. https://healthlog.example.com)`,
+ );
process.exit(2);
}
selected = [picked];
} else {
- selected = Object.values(TARGETS);
+ if (!TARGETS.prod) {
+ console.error(
+ "HEALTHLOG_PROD_URL is not set — set it to the instance base URL (e.g. https://healthlog.example.com) or use --only=demo",
+ );
+ process.exit(2);
+ }
+ selected = Object.values(TARGETS).filter((t): t is Target => Boolean(t));
}
console.log(
diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json
index 08c4fbea2..ab266a882 100644
--- a/scripts/env-manifest.json
+++ b/scripts/env-manifest.json
@@ -87,6 +87,10 @@
{
"name": "DEPLOY_WEBHOOK_SECRET",
"purpose": "Shared secret for the X-Deploy-Webhook-Secret header on POST /api/internal/deploy-webhook. 64 hex chars."
+ },
+ {
+ "name": "DEPLOY_LOGS_URL",
+ "purpose": "URL of the deployment dashboard (e.g. the Coolify UI) linked in deploy-failure notifications. Optional; when unset the notification omits the link."
}
]
},
diff --git a/scripts/repair-intake-anomalies.ts b/scripts/repair-intake-anomalies.ts
new file mode 100644
index 000000000..47efeadb7
--- /dev/null
+++ b/scripts/repair-intake-anomalies.ts
@@ -0,0 +1,381 @@
+/**
+ * scripts/repair-intake-anomalies.ts — operator repair for the two known
+ * historic medication-ledger defects:
+ *
+ * 1. SLOT DUPLICATES — more than one live `medication_intake_events` row
+ * on the same exact `(user_id, medication_id, scheduled_for)` tuple
+ * (cross-source duplicates the pre-v1.15.19 write path could mint).
+ * Repair: keep one winner per slot (H1 precedence, see `pickWinner`
+ * below) and soft-delete the losers — `deleted_at = now()`,
+ * `sync_version` incremented, `updated_at` bumped via `@updatedAt` —
+ * exactly the tombstone shape the per-event DELETE route and the
+ * `intake-slot-dedup` worker write, so iOS delta-sync drops them.
+ *
+ * 2. IMPLAUSIBLE TAKEN_AT — live rows whose `taken_at` lands more than
+ * 7 days before `scheduled_for` or more than 1 day in the future.
+ * User intention is unknowable here, so these are REPORTED only
+ * (correct or delete them in the medication history tab). With
+ * `--fix --tombstone-implausible` the operator can explicitly opt
+ * into soft-deleting them.
+ *
+ * After any mutation the affected `(user, medication, day)` compliance
+ * rollups are recomputed through the shared rollup helpers — no SQL is
+ * duplicated here. The rollup aggregates DISTINCT slots since v1.15.19,
+ * so a recompute alone already heals the counts; the tombstones
+ * additionally clear the phantom rows from the history view.
+ *
+ * Default is a DRY-RUN that only reports. Idempotent: a second `--fix`
+ * run finds zero groups and changes nothing.
+ *
+ * Usage
+ * -----
+ * pnpm dlx tsx scripts/repair-intake-anomalies.ts # dry-run
+ * pnpm dlx tsx scripts/repair-intake-anomalies.ts --user # scoped dry-run
+ * pnpm dlx tsx scripts/repair-intake-anomalies.ts --fix
+ * pnpm dlx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
+ *
+ * `DATABASE_URL` must point at the target database.
+ */
+import "dotenv/config";
+import { Prisma } from "@/generated/prisma/client";
+import { prisma } from "@/lib/db";
+import {
+ dayKeyForScheduledFor,
+ recomputeMedicationComplianceForDay,
+} from "@/lib/rollups/medication-compliance-rollups";
+
+const TAG = "[repair-intake-anomalies]";
+
+interface CliOptions {
+ fix: boolean;
+ tombstoneImplausible: boolean;
+ userId: string | null;
+}
+
+function parseArgs(argv: string[]): CliOptions {
+ const opts: CliOptions = {
+ fix: false,
+ tombstoneImplausible: false,
+ userId: null,
+ };
+ for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i];
+ if (arg === "--fix") {
+ opts.fix = true;
+ } else if (arg === "--tombstone-implausible") {
+ opts.tombstoneImplausible = true;
+ } else if (arg === "--user" && argv[i + 1]) {
+ opts.userId = argv[i + 1];
+ i += 1;
+ } else {
+ console.error(`${TAG} unknown argument: ${arg}`);
+ process.exit(2);
+ }
+ }
+ return opts;
+}
+
+interface IntakeRow {
+ id: string;
+ takenAt: Date | null;
+ skipped: boolean;
+ source: string;
+ createdAt: Date;
+}
+
+/**
+ * Rank a row for the slot-winner pick: taken (2) > skipped (1) >
+ * pending (0). `takenAt` set and `skipped` together still counts as
+ * taken — the recorded dose dominates.
+ */
+function rowRank(row: IntakeRow): number {
+ if (row.takenAt !== null) return 2;
+ if (row.skipped) return 1;
+ return 0;
+}
+
+/**
+ * Pick the winner among the live rows sharing one exact slot.
+ *
+ * H1 precedence — neither in-tree helper is exported, so the rule is
+ * rebuilt here; see `pickSlotRow` in
+ * `src/lib/medications/scheduling/slot-upsert.ts` (actioned > pending,
+ * deterministic `createdAt asc, id asc` tie-break) and `pickWinner` in
+ * `src/lib/medications/intake-slot-dedup.ts` (taken > skipped > pending):
+ *
+ * 1. actioned beats pending (never resurrect a phantom pending row;
+ * deleting a recorded dose would under-report adherence — the
+ * dangerous direction);
+ * 2. between two taken rows the EARLIER `takenAt` wins — the first
+ * recorded intake is the dose of record;
+ * 3. final tie-break `createdAt asc`, then `id asc` — fully
+ * deterministic across runs and DB orderings.
+ */
+function pickWinner(rows: IntakeRow[]): IntakeRow {
+ return [...rows].sort((a, b) => {
+ const rank = rowRank(b) - rowRank(a);
+ if (rank !== 0) return rank;
+ if (a.takenAt !== null && b.takenAt !== null) {
+ const dt = a.takenAt.getTime() - b.takenAt.getTime();
+ if (dt !== 0) return dt;
+ }
+ const created = a.createdAt.getTime() - b.createdAt.getTime();
+ if (created !== 0) return created;
+ return a.id < b.id ? -1 : 1;
+ })[0];
+}
+
+/** `userId -> timezone` cache for the rollup day-key derivation. */
+const tzCache = new Map();
+
+async function userTimezone(userId: string): Promise {
+ if (!tzCache.has(userId)) {
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { timezone: true },
+ });
+ tzCache.set(userId, user?.timezone ?? null);
+ }
+ return tzCache.get(userId) ?? null;
+}
+
+interface Summary {
+ duplicateGroups: number;
+ rowsTombstoned: number;
+ implausibleRows: number;
+ implausibleTombstoned: number;
+ rollupsRecomputed: number;
+}
+
+async function main(): Promise {
+ if (!process.env.DATABASE_URL) {
+ console.error(`${TAG} DATABASE_URL must be set`);
+ process.exit(1);
+ }
+
+ const opts = parseArgs(process.argv.slice(2));
+ if (opts.tombstoneImplausible && !opts.fix) {
+ console.error(
+ `${TAG} --tombstone-implausible requires --fix (dry-run never mutates)`,
+ );
+ process.exit(2);
+ }
+
+ console.log(
+ `${TAG} mode=${opts.fix ? "FIX" : "DRY-RUN"}` +
+ (opts.tombstoneImplausible ? " +tombstone-implausible" : "") +
+ (opts.userId ? ` user=${opts.userId}` : " (all users)"),
+ );
+
+ const summary: Summary = {
+ duplicateGroups: 0,
+ rowsTombstoned: 0,
+ implausibleRows: 0,
+ implausibleTombstoned: 0,
+ rollupsRecomputed: 0,
+ };
+
+ const userFilter = opts.userId
+ ? Prisma.sql`AND e."user_id" = ${opts.userId}`
+ : Prisma.empty;
+
+ // `(userId, medicationId, dayKey)` tuples whose compliance rollup needs
+ // a recompute after the tombstones. Deduped so a busy day folds once.
+ const daysToRecompute = new Set();
+
+ // ───────────────────────────────────────────────────────────────────
+ // 1. Slot duplicates — exact (user, medication, scheduled_for) tuples
+ // carrying more than one live row.
+ // ───────────────────────────────────────────────────────────────────
+ const groups = await prisma.$queryRaw<
+ Array<{
+ user_id: string;
+ medication_id: string;
+ scheduled_for: Date;
+ row_count: number;
+ }>
+ >`
+ SELECT e."user_id", e."medication_id", e."scheduled_for",
+ COUNT(*)::int AS row_count
+ FROM "medication_intake_events" e
+ WHERE e."deleted_at" IS NULL
+ ${userFilter}
+ GROUP BY e."user_id", e."medication_id", e."scheduled_for"
+ HAVING COUNT(*) > 1
+ ORDER BY e."user_id", e."medication_id", e."scheduled_for"
+ `;
+
+ summary.duplicateGroups = groups.length;
+ console.log(`\n${TAG} 1) duplicate slot groups found: ${groups.length}`);
+
+ for (const group of groups) {
+ const rows = (await prisma.medicationIntakeEvent.findMany({
+ where: {
+ userId: group.user_id,
+ medicationId: group.medication_id,
+ scheduledFor: group.scheduled_for,
+ deletedAt: null,
+ },
+ select: {
+ id: true,
+ takenAt: true,
+ skipped: true,
+ source: true,
+ createdAt: true,
+ },
+ orderBy: [{ createdAt: "asc" }, { id: "asc" }],
+ })) as IntakeRow[];
+ if (rows.length < 2) continue; // collapsed since the scan — nothing to do.
+
+ const winner = pickWinner(rows);
+ const losers = rows.filter((r) => r.id !== winner.id);
+
+ console.log(
+ ` - user=${group.user_id} medication=${group.medication_id} ` +
+ `slot=${group.scheduled_for.toISOString()} rows=${rows.length} ` +
+ `keep=${winner.id} (${winner.source}, ` +
+ `${winner.takenAt ? `taken ${winner.takenAt.toISOString()}` : winner.skipped ? "skipped" : "pending"}) ` +
+ `tombstone=[${losers.map((r) => `${r.id} (${r.source})`).join(", ")}]`,
+ );
+
+ if (opts.fix) {
+ // Same tombstone the per-event DELETE route and the dedup worker
+ // write: soft-delete + syncVersion bump (the sync feed echoes a
+ // monotonic value so iOS drops the row); `updatedAt` bumps via the
+ // schema's `@updatedAt`. The `deletedAt: null` guard keeps the
+ // write idempotent against a concurrent collapse.
+ const res = await prisma.medicationIntakeEvent.updateMany({
+ where: { id: { in: losers.map((r) => r.id) }, deletedAt: null },
+ data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
+ });
+ summary.rowsTombstoned += res.count;
+
+ const tz = await userTimezone(group.user_id);
+ const dayKey = dayKeyForScheduledFor(group.scheduled_for, tz);
+ daysToRecompute.add(
+ `${group.user_id}|${group.medication_id}|${dayKey}`,
+ );
+ }
+ }
+
+ // ───────────────────────────────────────────────────────────────────
+ // 2. Implausible taken_at — report only; tombstone needs explicit
+ // operator opt-in.
+ // ───────────────────────────────────────────────────────────────────
+ const implausible = await prisma.$queryRaw<
+ Array<{
+ id: string;
+ user_id: string;
+ medication_id: string;
+ medication_name: string;
+ scheduled_for: Date;
+ taken_at: Date;
+ source: string;
+ created_at: Date;
+ updated_at: Date;
+ }>
+ >`
+ SELECT e."id", e."user_id", e."medication_id",
+ m."name" AS medication_name,
+ e."scheduled_for", e."taken_at", e."source"::text AS source,
+ e."created_at", e."updated_at"
+ FROM "medication_intake_events" e
+ JOIN "medications" m ON m."id" = e."medication_id"
+ WHERE e."deleted_at" IS NULL
+ AND e."taken_at" IS NOT NULL
+ AND (e."taken_at" < e."scheduled_for" - interval '7 days'
+ OR e."taken_at" > now() + interval '1 day')
+ ${userFilter}
+ ORDER BY e."user_id", e."taken_at"
+ `;
+
+ summary.implausibleRows = implausible.length;
+ console.log(`\n${TAG} 2) implausible taken_at rows found: ${implausible.length}`);
+ if (implausible.length > 0) {
+ console.table(
+ implausible.map((r) => ({
+ id: r.id,
+ medication: r.medication_name,
+ scheduledFor: r.scheduled_for.toISOString(),
+ takenAt: r.taken_at.toISOString(),
+ source: r.source,
+ createdAt: r.created_at.toISOString(),
+ updatedAt: r.updated_at.toISOString(),
+ })),
+ );
+ if (!(opts.fix && opts.tombstoneImplausible)) {
+ console.log(
+ `${TAG} these rows are NOT changed automatically — the recorded ` +
+ `intent is unknowable. Correct or delete them in the medication ` +
+ `history tab, or re-run with --fix --tombstone-implausible to ` +
+ `soft-delete them.`,
+ );
+ }
+ }
+
+ if (opts.fix && opts.tombstoneImplausible && implausible.length > 0) {
+ const res = await prisma.medicationIntakeEvent.updateMany({
+ where: {
+ id: { in: implausible.map((r) => r.id) },
+ deletedAt: null,
+ },
+ data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
+ });
+ summary.implausibleTombstoned = res.count;
+ console.log(
+ `${TAG} tombstoned ${res.count} implausible row(s) (operator opt-in)`,
+ );
+
+ for (const row of implausible) {
+ const tz = await userTimezone(row.user_id);
+ const dayKey = dayKeyForScheduledFor(row.scheduled_for, tz);
+ daysToRecompute.add(`${row.user_id}|${row.medication_id}|${dayKey}`);
+ }
+ }
+
+ // ───────────────────────────────────────────────────────────────────
+ // 3. Recompute the affected compliance rollups so scheduled/taken
+ // counts self-correct. Shared helper — no SQL duplicated here.
+ // ───────────────────────────────────────────────────────────────────
+ if (opts.fix && daysToRecompute.size > 0) {
+ console.log(
+ `\n${TAG} 3) recomputing ${daysToRecompute.size} compliance rollup day(s)`,
+ );
+ for (const key of daysToRecompute) {
+ const [userId, medicationId, dayKey] = key.split("|");
+ try {
+ await recomputeMedicationComplianceForDay(
+ userId,
+ medicationId,
+ dayKey,
+ await userTimezone(userId),
+ );
+ summary.rollupsRecomputed += 1;
+ } catch (err) {
+ console.error(
+ `${TAG} rollup recompute failed for ${key}: ` +
+ `${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+ }
+ }
+
+ // ───────────────────────────────────────────────────────────────────
+ // Summary
+ // ───────────────────────────────────────────────────────────────────
+ console.log(`\n${TAG} ${opts.fix ? "DONE" : "DRY-RUN — nothing changed"}`);
+ console.log(` duplicate slot groups: ${summary.duplicateGroups}`);
+ console.log(` duplicate rows tombstoned: ${summary.rowsTombstoned}`);
+ console.log(` implausible taken_at rows: ${summary.implausibleRows}`);
+ console.log(` implausible rows tombstoned: ${summary.implausibleTombstoned}`);
+ console.log(` rollup days recomputed: ${summary.rollupsRecomputed}`);
+}
+
+main()
+ .catch((err) => {
+ console.error(`${TAG} fatal error:`, err);
+ process.exit(1);
+ })
+ .finally(async () => {
+ await prisma.$disconnect();
+ });
diff --git a/src/__tests__/proxy-csp-hsts-hardening.test.ts b/src/__tests__/proxy-csp-hsts-hardening.test.ts
index 39023d124..2624e1f1d 100644
--- a/src/__tests__/proxy-csp-hsts-hardening.test.ts
+++ b/src/__tests__/proxy-csp-hsts-hardening.test.ts
@@ -6,7 +6,7 @@ import { NextRequest } from "next/server";
* needed to harden against first-visit MITM and to scope the
* Withings backend host to its surface only.
*
- * - HSTS now carries `; preload` so `healthlog.bombeck.io` is
+ * - HSTS now carries `; preload` so the served host is
* eligible for the Chromium HSTS preload list.
* - The `wbsapi.withings.net` `connect-src` entry only ships on
* the Withings surfaces (`/settings/integrations/withings/*`
diff --git a/src/app/.well-known/apple-app-site-association/route.ts b/src/app/.well-known/apple-app-site-association/route.ts
index 5cddf1821..2f692e636 100644
--- a/src/app/.well-known/apple-app-site-association/route.ts
+++ b/src/app/.well-known/apple-app-site-association/route.ts
@@ -21,8 +21,8 @@ import { NextResponse } from "next/server";
* it to devices, so the origin only needs to respond reliably; Apple's
* one-hour CDN TTL pairs with the `Cache-Control` directive below.
*
- * Both `healthlog.bombeck.io` (maintainer prod) and `demo.healthlog.dev`
- * (demo) share this handler — the response is host-independent.
+ * Every host serving this app (an operator's instance, the public demo)
+ * shares this handler — the response is host-independent.
*/
const AASA_APP_ID = "S8WDX4W5KX.dev.healthlog.app";
diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx
index 01bb28386..7edbb69f5 100644
--- a/src/app/about/page.tsx
+++ b/src/app/about/page.tsx
@@ -170,14 +170,14 @@ export default function AboutPage() {
data-slot="about-footer"
>
- HealthLog — open source under{" "}
+ HealthLog — source-available under the{" "}
- AGPL-3.0
+ PolyForm Noncommercial License 1.0.0
. See{" "}
{
const rl = await checkRateLimit("admin-ai-settings", 10, 60_000);
if (!rl.allowed) throw new HttpError(429, "Too many requests");
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const { apiKey, model, baseUrl } = body as {
diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts
index 9c47ad1be..e8171168f 100644
--- a/src/app/api/admin/backups/[id]/restore/route.ts
+++ b/src/app/api/admin/backups/[id]/restore/route.ts
@@ -120,7 +120,11 @@ const handler = apiHandler(
let body: { confirm?: string } = {};
try {
- body = (await request.json()) as { confirm?: string };
+ const raw = await request.text();
+ if (raw.length > 64 * 1024) {
+ return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
+ }
+ body = JSON.parse(raw) as { confirm?: string };
} catch {
return apiError("Invalid JSON body", 400);
}
diff --git a/src/app/api/admin/data/route.ts b/src/app/api/admin/data/route.ts
index c24736982..444965d80 100644
--- a/src/app/api/admin/data/route.ts
+++ b/src/app/api/admin/data/route.ts
@@ -27,7 +27,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
let confirm = "";
try {
- const body = await request.json();
+ const raw = await request.text();
+ if (raw.length > 64 * 1024) {
+ return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
+ }
+ const body = JSON.parse(raw);
confirm = typeof body?.confirm === "string" ? body.confirm : "";
} catch {
return apiError("Invalid request", 422);
diff --git a/src/app/api/admin/drain-per-sample-cumulative/route.ts b/src/app/api/admin/drain-per-sample-cumulative/route.ts
index e34c1e7b7..98fbb3185 100644
--- a/src/app/api/admin/drain-per-sample-cumulative/route.ts
+++ b/src/app/api/admin/drain-per-sample-cumulative/route.ts
@@ -44,10 +44,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.drain.cumulative" } });
- const { data: rawBody, error: jsonError } = await safeJson(request);
+ const { data: rawBody, error: jsonError } = await safeJson(
+ request,
+ { maxBytes: 64 * 1024 },
+ );
if (jsonError) return jsonError;
- const body: DrainBody = (rawBody && typeof rawBody === "object") ? rawBody : {};
+ const body: DrainBody = rawBody && typeof rawBody === "object" ? rawBody : {};
const userId =
typeof body.userId === "string" && body.userId.length > 0
? body.userId
@@ -88,7 +91,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
- if (summary.totals.bucketsCollapsed === 0 && summary.totals.usersScanned === 0 && userId) {
+ if (
+ summary.totals.bucketsCollapsed === 0 &&
+ summary.totals.usersScanned === 0 &&
+ userId
+ ) {
return apiError("User not found", 404);
}
diff --git a/src/app/api/admin/feedback/[id]/route.ts b/src/app/api/admin/feedback/[id]/route.ts
index 624e21c39..ede8425f6 100644
--- a/src/app/api/admin/feedback/[id]/route.ts
+++ b/src/app/api/admin/feedback/[id]/route.ts
@@ -21,7 +21,9 @@ export const PATCH = apiHandler(
const { user } = await requireAdmin();
const { id } = await ctx.params;
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = updateFeedbackSchema.safeParse(body);
diff --git a/src/app/api/admin/rollups/recompute/route.ts b/src/app/api/admin/rollups/recompute/route.ts
index 9571b0d40..480b36a97 100644
--- a/src/app/api/admin/rollups/recompute/route.ts
+++ b/src/app/api/admin/rollups/recompute/route.ts
@@ -48,8 +48,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.rollups.recompute" } });
- const { data: rawBody, error: jsonError } =
- await safeJson(request);
+ const { data: rawBody, error: jsonError } = await safeJson(
+ request,
+ { maxBytes: 64 * 1024 },
+ );
if (jsonError) return jsonError;
const body: RecomputeBody =
diff --git a/src/app/api/admin/settings/assistant-flags/route.ts b/src/app/api/admin/settings/assistant-flags/route.ts
index 0782555a1..fd4fd451e 100644
--- a/src/app/api/admin/settings/assistant-flags/route.ts
+++ b/src/app/api/admin/settings/assistant-flags/route.ts
@@ -120,7 +120,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.settings.assistant-flags.update" } });
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = assistantFlagsSchema.safeParse(body);
diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts
index 39ea7397b..ac276b313 100644
--- a/src/app/api/admin/settings/route.ts
+++ b/src/app/api/admin/settings/route.ts
@@ -67,7 +67,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.settings.update" } });
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = adminSettingsSchema.safeParse(body);
diff --git a/src/app/api/admin/users/[id]/reset-password/route.ts b/src/app/api/admin/users/[id]/reset-password/route.ts
index 9fa2e5ffd..add5d7cd5 100644
--- a/src/app/api/admin/users/[id]/reset-password/route.ts
+++ b/src/app/api/admin/users/[id]/reset-password/route.ts
@@ -39,7 +39,9 @@ export const POST = apiHandler(
},
});
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const { password } = body as { password?: string };
diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts
index 43fb4151c..d682f316f 100644
--- a/src/app/api/admin/users/[id]/route.ts
+++ b/src/app/api/admin/users/[id]/route.ts
@@ -34,7 +34,9 @@ export const PUT = apiHandler(
},
});
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = updateUserSchema.safeParse(body);
diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts
index 1dbf3decb..221e53ec0 100644
--- a/src/app/api/ai/test/route.ts
+++ b/src/app/api/ai/test/route.ts
@@ -39,7 +39,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
let override: AITestOverride = {};
const contentLength = Number(request.headers.get("content-length") ?? "0");
if (contentLength > 0) {
- const { data, error } = await safeJson(request);
+ const { data, error } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (error) return error;
const parsed = overrideSchema.safeParse(data);
if (!parsed.success) {
@@ -140,7 +142,8 @@ function classifyTestFailure(
if (status === 401 || status === 403 || (status >= 500 && looksLikeAuth)) {
return {
reasonCode: "credentials",
- reason: "Provider rejected the credentials — re-authenticate in AI settings.",
+ reason:
+ "Provider rejected the credentials — re-authenticate in AI settings.",
};
}
if (status === 429) {
diff --git a/src/app/api/auth/check-user/route.ts b/src/app/api/auth/check-user/route.ts
index b0a63373a..c79b0a96c 100644
--- a/src/app/api/auth/check-user/route.ts
+++ b/src/app/api/auth/check-user/route.ts
@@ -42,10 +42,7 @@ import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
-import {
- checkAuthSurfaceRateLimit,
- rateLimitHeaders,
-} from "@/lib/rate-limit";
+import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
@@ -77,7 +74,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
}
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = bodySchema.safeParse(body);
@@ -109,7 +108,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
const identifierHash = hashToken(identifier);
if (!user) {
- annotate({ action: { name: "auth.check-user" }, meta: { branch: "not_found" } });
+ annotate({
+ action: { name: "auth.check-user" },
+ meta: { branch: "not_found" },
+ });
await auditLog("auth.check-user", {
ipAddress: ip,
details: { branch: "not_found", identifier_hash: identifierHash },
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts
index ea96560cb..c4e727ee0 100644
--- a/src/app/api/auth/login/route.ts
+++ b/src/app/api/auth/login/route.ts
@@ -4,11 +4,7 @@ import { verifyPassword } from "@/lib/auth/password";
import { createSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
-import {
- apiSuccess,
- apiError,
- safeJson,
-} from "@/lib/api-response";
+import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { NextRequest, NextResponse } from "next/server";
@@ -45,7 +41,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
await ensureDbCompatibility();
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = loginPasswordSchema.safeParse(body);
diff --git a/src/app/api/auth/me/devices/[id]/route.ts b/src/app/api/auth/me/devices/[id]/route.ts
index 7d1630300..89f5ca688 100644
--- a/src/app/api/auth/me/devices/[id]/route.ts
+++ b/src/app/api/auth/me/devices/[id]/route.ts
@@ -54,7 +54,9 @@ export const PATCH = apiHandler(
const { user } = await requireAuth();
const { id } = await context.params;
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = devicePatchSchema.safeParse(body);
if (!parsed.success) {
@@ -82,7 +84,9 @@ export const PATCH = apiHandler(
entity_type: "device",
entity_id: id,
},
- meta: { medication_delivery: parsed.data.medicationDelivery ?? "inherit" },
+ meta: {
+ medication_delivery: parsed.data.medicationDelivery ?? "inherit",
+ },
});
return apiSuccess({
diff --git a/src/app/api/auth/me/injection-site-prefs/route.ts b/src/app/api/auth/me/injection-site-prefs/route.ts
index b265988f0..3a1fb6360 100644
--- a/src/app/api/auth/me/injection-site-prefs/route.ts
+++ b/src/app/api/auth/me/injection-site-prefs/route.ts
@@ -91,7 +91,9 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
return response;
}
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = patchBodySchema.safeParse(body);
diff --git a/src/app/api/auth/me/timezone/route.ts b/src/app/api/auth/me/timezone/route.ts
index e5b849fcb..1ac07297a 100644
--- a/src/app/api/auth/me/timezone/route.ts
+++ b/src/app/api/auth/me/timezone/route.ts
@@ -34,7 +34,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "user.timezone.update" } });
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const tz =
diff --git a/src/app/api/auth/passkey/login-verify/route.ts b/src/app/api/auth/passkey/login-verify/route.ts
index ad6241bcb..18178dd64 100644
--- a/src/app/api/auth/passkey/login-verify/route.ts
+++ b/src/app/api/auth/passkey/login-verify/route.ts
@@ -1,11 +1,7 @@
import { verifyAuthentication } from "@/lib/auth/passkey";
import { createSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
-import {
- apiSuccess,
- apiError,
- safeJson,
-} from "@/lib/api-response";
+import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
@@ -35,8 +31,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Too many attempts. Please wait 15 minutes.", 429);
}
- const { data: body, error: jsonError } =
- await safeJson>(request);
+ const { data: body, error: jsonError } = await safeJson<
+ Record
+ >(request, { maxBytes: 64 * 1024 });
if (jsonError) return jsonError;
const challengeId = body.challengeId as string | undefined;
diff --git a/src/app/api/auth/passkey/register-verify/route.ts b/src/app/api/auth/passkey/register-verify/route.ts
index 14c405f2c..1b9ae3b84 100644
--- a/src/app/api/auth/passkey/register-verify/route.ts
+++ b/src/app/api/auth/passkey/register-verify/route.ts
@@ -14,8 +14,9 @@ import { annotate } from "@/lib/logging/context";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
- const { data: body, error: jsonError } =
- await safeJson>(request);
+ const { data: body, error: jsonError } = await safeJson<
+ Record
+ >(request, { maxBytes: 64 * 1024 });
if (jsonError) return jsonError;
const challengeId = body.challengeId as string | undefined;
diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts
index 79e4a7868..828304e8a 100644
--- a/src/app/api/auth/password/route.ts
+++ b/src/app/api/auth/password/route.ts
@@ -32,7 +32,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Too many attempts. Please wait 15 minutes.", 429);
}
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = changePasswordSchema.safeParse(body);
diff --git a/src/app/api/auth/profile/route.ts b/src/app/api/auth/profile/route.ts
index c84a59771..8b9d17347 100644
--- a/src/app/api/auth/profile/route.ts
+++ b/src/app/api/auth/profile/route.ts
@@ -12,7 +12,9 @@ import { applyProfileUpdate } from "@/lib/auth/profile-update";
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const result = await applyProfileUpdate(user.id, body, getClientIp(request));
diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts
index 9c7d75633..adb89145f 100644
--- a/src/app/api/auth/refresh/route.ts
+++ b/src/app/api/auth/refresh/route.ts
@@ -4,11 +4,7 @@
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
-import {
- apiSuccess,
- apiError,
- safeJson,
-} from "@/lib/api-response";
+import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
@@ -42,7 +38,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { data: body, error: jsonError } = await safeJson<{
refreshToken?: string;
revoke?: boolean;
- }>(request);
+ }>(request, { maxBytes: 64 * 1024 });
if (jsonError) return jsonError;
if (!body.refreshToken || typeof body.refreshToken !== "string") {
return apiError("refreshToken required", 422);
diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts
index 71c327222..3a3080207 100644
--- a/src/app/api/auth/register/route.ts
+++ b/src/app/api/auth/register/route.ts
@@ -61,7 +61,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Registration is disabled", 403);
}
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = registerSchema.safeParse(body);
diff --git a/src/app/api/bugreport/route.ts b/src/app/api/bugreport/route.ts
index 0a9aedc55..9f2c24fa2 100644
--- a/src/app/api/bugreport/route.ts
+++ b/src/app/api/bugreport/route.ts
@@ -80,7 +80,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
}
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 8 * 1024 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = bugReportSchema.safeParse(body);
diff --git a/src/app/api/consent/ai/route.ts b/src/app/api/consent/ai/route.ts
index fb2d6ab16..8aa2cbb26 100644
--- a/src/app/api/consent/ai/route.ts
+++ b/src/app/api/consent/ai/route.ts
@@ -9,11 +9,7 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
-import {
- apiSuccess,
- returnAllZodIssues,
- safeJson,
-} from "@/lib/api-response";
+import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { consentPostBody } from "@/lib/validations/consent";
@@ -22,7 +18,9 @@ import { createReceipt } from "@/lib/consent/receipts";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
- const { data: body, error } = await safeJson(request);
+ const { data: body, error } = await safeJson(request, {
+ maxBytes: 128 * 1024,
+ });
if (error) return error;
const parsed = consentPostBody.safeParse(body);
diff --git a/src/app/api/cycle/day-logs/[id]/route.ts b/src/app/api/cycle/day-logs/[id]/route.ts
index 87d4e32f7..2d72868e0 100644
--- a/src/app/api/cycle/day-logs/[id]/route.ts
+++ b/src/app/api/cycle/day-logs/[id]/route.ts
@@ -56,7 +56,9 @@ export const PATCH = apiHandler(
return apiError("Day-log not found", 404);
}
- const { data: rawBody, error: jsonError } = await safeJson(request);
+ const { data: rawBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = cycleDayLogPatchSchema.safeParse(rawBody);
diff --git a/src/app/api/cycle/day-logs/bulk/__tests__/route.test.ts b/src/app/api/cycle/day-logs/bulk/__tests__/route.test.ts
new file mode 100644
index 000000000..bc177e247
--- /dev/null
+++ b/src/app/api/cycle/day-logs/bulk/__tests__/route.test.ts
@@ -0,0 +1,96 @@
+/**
+ * POST /api/cycle/day-logs/bulk — body-size cap.
+ *
+ * The bulk drain accepts up to 500 entries; the `safeJson` cap (2 MB)
+ * rejects an oversized body with 413 before `JSON.parse` builds an
+ * object graph.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { NextRequest } from "next/server";
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ auditLog: { create: vi.fn() },
+ },
+}));
+
+vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
+vi.mock("@/lib/auth/audit", () => ({
+ auditLog: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("@/lib/rate-limit", () => ({
+ checkRateLimit: vi.fn(),
+ rateLimitHeaders: () => ({}),
+}));
+vi.mock("@/lib/idempotency", () => ({
+ withIdempotency:
+ (fn: (...args: Args) => Promise) =>
+ (...args: Args) =>
+ fn(...args),
+}));
+vi.mock("@/lib/cycle/gate", () => ({
+ requireCycleEnabled: vi.fn(),
+}));
+vi.mock("@/lib/cycle/day-log-write", () => ({
+ upsertCycleDayLog: vi.fn(),
+}));
+vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("next/headers", () => ({
+ headers: vi.fn(async () => ({ get: () => null })),
+ cookies: vi.fn(async () => ({
+ get: () => undefined,
+ set: () => {},
+ delete: () => {},
+ })),
+}));
+
+import { POST } from "../route";
+import { getSession } from "@/lib/auth/session";
+import { checkRateLimit } from "@/lib/rate-limit";
+import { requireCycleEnabled } from "@/lib/cycle/gate";
+import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
+
+const SESSION_OK = {
+ session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
+ user: { id: "user-1", username: "tester", role: "USER" as const },
+};
+
+function postReq(body: unknown): NextRequest {
+ return new NextRequest("http://localhost/api/cycle/day-logs/bulk", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+}
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
+ vi.mocked(requireCycleEnabled).mockResolvedValue({
+ enabled: true,
+ } as never);
+});
+
+describe("POST /api/cycle/day-logs/bulk — body cap", () => {
+ it("rejects a body over the 2 MB cap with 413 before parsing", async () => {
+ const res = await POST(
+ postReq({ entries: [], pad: "x".repeat(2 * 1024 * 1024) }),
+ );
+ expect(res.status).toBe(413);
+ expect(upsertCycleDayLog).not.toHaveBeenCalled();
+ });
+
+ it("still rejects an over-cap entries array with 422 below the byte cap", async () => {
+ const entries = Array.from({ length: 501 }, (_, i) => ({
+ date: "2026-01-01",
+ externalId: `e${i}`,
+ }));
+ const res = await POST(postReq({ entries }));
+ expect(res.status).toBe(422);
+ expect(upsertCycleDayLog).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/api/cycle/day-logs/bulk/route.ts b/src/app/api/cycle/day-logs/bulk/route.ts
index d498ccade..5c1b7f225 100644
--- a/src/app/api/cycle/day-logs/bulk/route.ts
+++ b/src/app/api/cycle/day-logs/bulk/route.ts
@@ -64,7 +64,9 @@ async function postBulk(request: NextRequest): Promise {
return apiError("Too many bulk submissions, try again later", 429);
}
- const { data: rawBody, error: jsonError } = await safeJson(request);
+ const { data: rawBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 2 * 1024 * 1024,
+ });
if (jsonError) return jsonError;
if (
diff --git a/src/app/api/cycle/day-logs/route.ts b/src/app/api/cycle/day-logs/route.ts
index 91a7c0f09..05f48effc 100644
--- a/src/app/api/cycle/day-logs/route.ts
+++ b/src/app/api/cycle/day-logs/route.ts
@@ -77,7 +77,9 @@ async function postDayLog(request: NextRequest): Promise {
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
- const { data: rawBody, error: jsonError } = await safeJson(request);
+ const { data: rawBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = cycleDayLogInputSchema.safeParse(rawBody);
diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts
index 908ea889c..08b176672 100644
--- a/src/app/api/cycle/period/route.ts
+++ b/src/app/api/cycle/period/route.ts
@@ -42,7 +42,9 @@ async function postPeriod(request: NextRequest): Promise {
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
- const { data: rawBody, error: jsonError } = await safeJson(request);
+ const { data: rawBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = cyclePeriodSchema.safeParse(rawBody);
diff --git a/src/app/api/cycle/symptoms/custom/[key]/route.ts b/src/app/api/cycle/symptoms/custom/[key]/route.ts
index 1d9b74d4b..7db44e80f 100644
--- a/src/app/api/cycle/symptoms/custom/[key]/route.ts
+++ b/src/app/api/cycle/symptoms/custom/[key]/route.ts
@@ -14,6 +14,7 @@ import {
apiError,
getClientIp,
returnAllZodIssues,
+ safeJson,
} from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -52,7 +53,11 @@ export const PATCH = apiHandler(
const { key } = await params;
if (!isCustomSymptomKey(key)) return apiError("Not a custom symptom", 404);
- const parsed = updateCustomSymptomSchema.safeParse(await request.json());
+ const { data: rawJsonBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
+ if (jsonError) return jsonError;
+ const parsed = updateCustomSymptomSchema.safeParse(rawJsonBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "cycle.symptom.custom.invalid",
diff --git a/src/app/api/cycle/symptoms/custom/route.ts b/src/app/api/cycle/symptoms/custom/route.ts
index 956f93c41..b250aeb1f 100644
--- a/src/app/api/cycle/symptoms/custom/route.ts
+++ b/src/app/api/cycle/symptoms/custom/route.ts
@@ -22,6 +22,7 @@ import {
apiError,
getClientIp,
returnAllZodIssues,
+ safeJson,
} from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -74,12 +75,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
// Cap mutation rate so a single session can't flood the encrypted-label
// catalogue (each create runs an encrypt + audit write).
- const rl = await checkRateLimit(`cycle:symptom:custom:${user.id}`, 30, 60_000);
+ const rl = await checkRateLimit(
+ `cycle:symptom:custom:${user.id}`,
+ 30,
+ 60_000,
+ );
if (!rl.allowed) {
return apiError("Too many requests, try again later", 429);
}
- const parsed = createCustomSymptomSchema.safeParse(await request.json());
+ const { data: rawJsonBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
+ if (jsonError) return jsonError;
+ const parsed = createCustomSymptomSchema.safeParse(rawJsonBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "cycle.symptom.custom.invalid",
diff --git a/src/app/api/dashboard/chart-overlay-prefs/route.ts b/src/app/api/dashboard/chart-overlay-prefs/route.ts
index 87a8f4050..1a84a9cd1 100644
--- a/src/app/api/dashboard/chart-overlay-prefs/route.ts
+++ b/src/app/api/dashboard/chart-overlay-prefs/route.ts
@@ -50,7 +50,9 @@ const prefsSchema = z.object({
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = prefsSchema.safeParse(body);
diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts
index 5ab6e51d7..692b7ed70 100644
--- a/src/app/api/dashboard/widgets/route.ts
+++ b/src/app/api/dashboard/widgets/route.ts
@@ -143,7 +143,9 @@ export const GET = apiHandler(async () => {
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 256 * 1024,
+ });
if (jsonError) return jsonError;
// v1.7.0 W1 — accept-and-ignore widget ids OUTSIDE the 27-id catalogue
@@ -165,8 +167,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const droppedIds = widgetsBody.widgets
.map((w) => w?.id)
.filter(
- (id): id is string =>
- typeof id === "string" && !knownWidgetIds.has(id),
+ (id): id is string => typeof id === "string" && !knownWidgetIds.has(id),
);
if (droppedIds.length > 0) {
widgetsBody.widgets = widgetsBody.widgets.filter(
@@ -208,7 +209,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// future field matching the denylist (password / token / secret /
// apiKey / authorization / csrfState / nonce) lands as the literal
// `"[redacted]"` instead of its raw value.
- const payloadDiagnostic = buildPayloadDiagnostic(redactSensitiveFields(body));
+ const payloadDiagnostic = buildPayloadDiagnostic(
+ redactSensitiveFields(body),
+ );
annotate({
action: { name: "dashboard.widgets.validation-failed" },
meta: {
diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts
index e1f65196a..98dc17e38 100644
--- a/src/app/api/devices/route.ts
+++ b/src/app/api/devices/route.ts
@@ -100,7 +100,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Too many device registrations, please slow down", 429);
}
- const { data: body, error } = await safeJson(request);
+ const { data: body, error } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (error) return error;
const parsed = deviceSchema.safeParse(body);
diff --git a/src/app/api/doctor-report/availability/route.ts b/src/app/api/doctor-report/availability/route.ts
index 32bf3e2ac..e2a818a95 100644
--- a/src/app/api/doctor-report/availability/route.ts
+++ b/src/app/api/doctor-report/availability/route.ts
@@ -36,7 +36,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "doctor-report.availability" } });
- const { data: body, error } = await safeJson(request);
+ const { data: body, error } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (error) return error;
const range = normaliseDateRange(body ?? undefined);
const { start, end } = range;
diff --git a/src/app/api/doctor-report/pdf/route.ts b/src/app/api/doctor-report/pdf/route.ts
index 07667d42a..d64f12467 100644
--- a/src/app/api/doctor-report/pdf/route.ts
+++ b/src/app/api/doctor-report/pdf/route.ts
@@ -146,7 +146,10 @@ async function readOptionalJsonBody(
const contentType = request.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) return null;
try {
- return (await request.json()) as PdfRequestBody;
+ const raw = await request.text();
+ // Small options object — treat an oversized body as no body.
+ if (raw.length > 64 * 1024) return null;
+ return JSON.parse(raw) as PdfRequestBody;
} catch {
return null;
}
diff --git a/src/app/api/doctor-report/route.ts b/src/app/api/doctor-report/route.ts
index f601aa62f..776009f70 100644
--- a/src/app/api/doctor-report/route.ts
+++ b/src/app/api/doctor-report/route.ts
@@ -44,7 +44,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Maximum 10 reports per hour", 429);
}
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const range = normaliseDateRange(body);
diff --git a/src/app/api/feedback/route.ts b/src/app/api/feedback/route.ts
index e35bb53ce..2547bb3af 100644
--- a/src/app/api/feedback/route.ts
+++ b/src/app/api/feedback/route.ts
@@ -41,7 +41,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Bug reports are disabled by the administrator", 503);
}
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = createFeedbackSchema.safeParse(body);
diff --git a/src/app/api/fitbit/credentials/route.ts b/src/app/api/fitbit/credentials/route.ts
index b2cabfee1..1a57ae5fe 100644
--- a/src/app/api/fitbit/credentials/route.ts
+++ b/src/app/api/fitbit/credentials/route.ts
@@ -37,7 +37,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "fitbit.credentials.update" } });
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const result = z.safeParse(fitbitCredentialsSchema, body);
diff --git a/src/app/api/fitbit/sync/route.ts b/src/app/api/fitbit/sync/route.ts
index d37696252..02c1b1b2d 100644
--- a/src/app/api/fitbit/sync/route.ts
+++ b/src/app/api/fitbit/sync/route.ts
@@ -31,7 +31,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
let fullSync = false;
try {
- const body = await request.json();
+ const raw = await request.text();
+ // Flag-only payload — cap the parse cost (mirrors safeJson maxBytes).
+ if (raw.length > 64 * 1024) {
+ return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
+ }
+ const body = JSON.parse(raw);
fullSync = body?.fullSync === true;
} catch {
// no body provided -> default incremental sync
diff --git a/src/app/api/import/__tests__/route.test.ts b/src/app/api/import/__tests__/route.test.ts
index b4748cbc0..dacb73085 100644
--- a/src/app/api/import/__tests__/route.test.ts
+++ b/src/app/api/import/__tests__/route.test.ts
@@ -106,6 +106,21 @@ describe("POST /api/import — rate-limit guard", () => {
expect(response.status).toBeLessThan(400);
});
+ it("rejects a body over the 16 MB cap with 413 before parsing", async () => {
+ vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
+ const response = await POST(
+ new NextRequest("http://localhost/api/import", {
+ method: "POST",
+ body: JSON.stringify({
+ measurements: [],
+ pad: "x".repeat(16 * 1024 * 1024),
+ }),
+ headers: { "content-type": "application/json" },
+ }),
+ );
+ expect(response.status).toBe(413);
+ });
+
it("returns the standard apiError envelope for invalid payloads", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts
index 5b55362ef..9785616d3 100644
--- a/src/app/api/import/route.ts
+++ b/src/app/api/import/route.ts
@@ -76,7 +76,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Maximum 5 imports per hour", 429);
}
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 16 * 1024 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = importSchema.safeParse(body);
diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts
index d3a937178..fe75ffda1 100644
--- a/src/app/api/ingest/medication/route.ts
+++ b/src/app/api/ingest/medication/route.ts
@@ -89,7 +89,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
data: { lastUsedAt: new Date() },
});
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = externalIntakeSchema.safeParse(body);
diff --git a/src/app/api/insights/chat/messages/[id]/feedback/route.ts b/src/app/api/insights/chat/messages/[id]/feedback/route.ts
index 22d8dd757..7b0342c98 100644
--- a/src/app/api/insights/chat/messages/[id]/feedback/route.ts
+++ b/src/app/api/insights/chat/messages/[id]/feedback/route.ts
@@ -79,7 +79,9 @@ async function handlePost(request: NextRequest, ctx: RouteContext) {
return apiError("Too many feedback submissions, try again later", 429);
}
- const { data: rawBody, error } = await safeJson(request);
+ const { data: rawBody, error } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (error) return error;
const parsed = coachMessageFeedbackSchema.safeParse(rawBody);
if (!parsed.success) {
diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts
index 85e2aff5e..90acb8440 100644
--- a/src/app/api/insights/chat/route.ts
+++ b/src/app/api/insights/chat/route.ts
@@ -157,8 +157,13 @@ async function handleChatRequest(request: NextRequest): Promise {
let body: unknown;
try {
- body = await request.json();
- } catch {
+ const raw = await request.text();
+ if (raw.length > 64 * 1024) {
+ throw new HttpError(413, `Request body exceeds ${64 * 1024} bytes`);
+ }
+ body = JSON.parse(raw);
+ } catch (err) {
+ if (err instanceof HttpError) throw err;
throw new HttpError(400, "Invalid JSON body");
}
const parsed = coachChatRequestSchema.safeParse(body);
diff --git a/src/app/api/insights/feedback/route.ts b/src/app/api/insights/feedback/route.ts
index 938a194eb..a8b30b5e7 100644
--- a/src/app/api/insights/feedback/route.ts
+++ b/src/app/api/insights/feedback/route.ts
@@ -55,7 +55,9 @@ async function handlePost(request: NextRequest) {
return apiError("Too many feedback submissions, try again later", 429);
}
- const { data: rawBody, error } = await safeJson(request);
+ const { data: rawBody, error } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (error) return error;
const parsed = recommendationFeedbackRequestSchema.safeParse(rawBody);
diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts
index b9fa0b3e0..41f147185 100644
--- a/src/app/api/insights/generate/__tests__/route.test.ts
+++ b/src/app/api/insights/generate/__tests__/route.test.ts
@@ -99,10 +99,9 @@ vi.mock("@/lib/logging/context", () => ({
}));
vi.mock("@/lib/insights/features", async () => {
- const actual =
- await vi.importActual(
- "@/lib/insights/features",
- );
+ const actual = await vi.importActual<
+ typeof import("@/lib/insights/features")
+ >("@/lib/insights/features");
return {
...actual,
extractFeatures: vi.fn(async () => ({ stub: true })),
@@ -193,6 +192,13 @@ function makeProviderThatThrows(
}
describe("POST /api/insights/generate — provider error mapping", () => {
+ it("rejects a body over the 16 KB cap with 413 before parsing", async () => {
+ const res = await POST(
+ jsonRequest({ force: true, pad: "x".repeat(16 * 1024) }) as never,
+ );
+ expect(res.status).toBe(413);
+ });
+
it("maps a 401 from the provider to 422 with a readable message", async () => {
const err = Object.assign(new Error("OpenAI request failed (401)"), {
httpStatus: 401,
@@ -421,7 +427,10 @@ describe("POST /api/insights/generate — payload-size hard downgrade (H1)", ()
(call[0] as { meta?: Record })?.meta
?.insights_payload_too_large === true,
);
- expect(annotated, "annotate event with insights_payload_too_large").toBeTruthy();
+ expect(
+ annotated,
+ "annotate event with insights_payload_too_large",
+ ).toBeTruthy();
});
});
diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts
index 4b4ded190..5b59b3ad3 100644
--- a/src/app/api/insights/generate/route.ts
+++ b/src/app/api/insights/generate/route.ts
@@ -1,6 +1,11 @@
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
-import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
+import {
+ apiSuccess,
+ apiError,
+ getClientIp,
+ safeJson,
+} from "@/lib/api-response";
import {
extractFeatures,
FeaturesPayloadTooLargeError,
@@ -267,8 +272,7 @@ export const GET = apiHandler(async () => {
const cachedAt = dbUser?.insightsCachedAt ?? null;
const isFresh =
- cachedAt !== null &&
- Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS;
+ cachedAt !== null && Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS;
// Read-only: never block on the provider. Warm out of band only when the
// cached briefing is stale / missing AND a provider is configured (a
@@ -341,7 +345,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
userLocale: dbUser?.locale ?? user.locale ?? null,
});
- const body = await request.json().catch(() => ({}));
+ const { data: body, error: jsonError } = await safeJson<{
+ force?: unknown;
+ }>(request, { maxBytes: 16 * 1024 });
+ if (jsonError) return jsonError;
const forceRefresh = body.force === true;
if (
diff --git a/src/app/api/insights/layout/route.ts b/src/app/api/insights/layout/route.ts
index 551c8aed0..cc59124f1 100644
--- a/src/app/api/insights/layout/route.ts
+++ b/src/app/api/insights/layout/route.ts
@@ -117,7 +117,9 @@ export const GET = apiHandler(async () => {
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 256 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = layoutSchema.safeParse(body);
@@ -127,7 +129,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// round-trip; the wide-event line carries the redacted payload
// shape for operator debugging without leaking the raw body.
const issues = sanitiseZodIssues(parsed.error.issues);
- const payloadDiagnostic = buildPayloadDiagnostic(redactSensitiveFields(body));
+ const payloadDiagnostic = buildPayloadDiagnostic(
+ redactSensitiveFields(body),
+ );
annotate({
action: { name: "insights.layout.validation-failed" },
meta: {
diff --git a/src/app/api/insights/provider-chain/route.ts b/src/app/api/insights/provider-chain/route.ts
index 24036d1f1..572cae426 100644
--- a/src/app/api/insights/provider-chain/route.ts
+++ b/src/app/api/insights/provider-chain/route.ts
@@ -100,7 +100,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "insights.provider_chain.update" } });
- const { data: body, error } = await safeJson(request);
+ const { data: body, error } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (error) return error;
const parsed = chainBodySchema.safeParse(body);
diff --git a/src/app/api/insights/settings/route.ts b/src/app/api/insights/settings/route.ts
index 7f7a5c7f5..6fb8d79f6 100644
--- a/src/app/api/insights/settings/route.ts
+++ b/src/app/api/insights/settings/route.ts
@@ -42,8 +42,9 @@ export const GET = apiHandler(async () => {
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
- const { data: body, error: jsonError } =
- await safeJson>(request);
+ const { data: body, error: jsonError } = await safeJson<
+ Record
+ >(request, { maxBytes: 64 * 1024 });
if (jsonError) return jsonError;
const data: Record = {};
diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts
index 44951704f..e0ba191f9 100644
--- a/src/app/api/integrations/healthkit/route.ts
+++ b/src/app/api/integrations/healthkit/route.ts
@@ -139,7 +139,9 @@ export const GET = apiHandler(async () => {
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
- const { data: body, error } = await safeJson(request);
+ const { data: body, error } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (error) return error;
const parsed = patchSchema.safeParse(body);
diff --git a/src/app/api/integrations/moodlog/sync/route.ts b/src/app/api/integrations/moodlog/sync/route.ts
index 7a05fd1d7..d2dea2455 100644
--- a/src/app/api/integrations/moodlog/sync/route.ts
+++ b/src/app/api/integrations/moodlog/sync/route.ts
@@ -22,7 +22,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
let fullSync = false;
try {
- const body = await request.json();
+ const raw = await request.text();
+ // Flag-only payload — cap the parse cost (mirrors safeJson maxBytes).
+ if (raw.length > 64 * 1024) {
+ return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
+ }
+ const body = JSON.parse(raw);
fullSync = body?.fullSync === true;
} catch {
// No body or invalid JSON — use defaults
diff --git a/src/app/api/integrations/moodlog/webhook/route.ts b/src/app/api/integrations/moodlog/webhook/route.ts
index c12e5daab..827a66fcf 100644
--- a/src/app/api/integrations/moodlog/webhook/route.ts
+++ b/src/app/api/integrations/moodlog/webhook/route.ts
@@ -74,7 +74,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
// 5. Parse body
let body: Record;
try {
- body = await request.json();
+ const raw = await request.text();
+ if (raw.length > 256 * 1024) {
+ return apiError(`Request body exceeds ${256 * 1024} bytes`, 413);
+ }
+ body = JSON.parse(raw);
} catch {
return apiError("Invalid JSON", 400);
}
diff --git a/src/app/api/internal/deploy-webhook/route.ts b/src/app/api/internal/deploy-webhook/route.ts
index 12baf3bb0..25d0a583a 100644
--- a/src/app/api/internal/deploy-webhook/route.ts
+++ b/src/app/api/internal/deploy-webhook/route.ts
@@ -9,7 +9,7 @@ import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-loca
import { prisma } from "@/lib/db";
/**
- * Deploy-status webhook (phase C2 / v1.4.15).
+ * Deploy-status webhook (v1.4.15).
*
* Coolify (Settings → Notifications → Webhook) calls this endpoint after
* every deploy attempt. We verify the request via a shared secret in the
@@ -135,7 +135,10 @@ async function notifyAdminsOfFailure(event: NormalizedEvent): Promise {
application: event.applicationName,
error: event.error ?? "",
deployment: event.deploymentUuid ?? "",
- logsUrl: "https://apps-01.bombeck.io",
+ // Optional: operators can point this at their deployment
+ // dashboard (e.g. the Coolify UI). Falls back to an empty
+ // string so the body template still composes cleanly.
+ logsUrl: process.env.DEPLOY_LOGS_URL ?? "",
},
metadata: {
source: "deploy-webhook",
@@ -173,7 +176,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
getEvent()?.setAuth({ auth_method: "webhook_secret" });
const { data: payload, error: jsonError } =
- await safeJson(request);
+ await safeJson(request, { maxBytes: 64 * 1024 });
if (jsonError) return jsonError;
const event = normalizePayload(payload);
diff --git a/src/app/api/internal/web-vitals/route.ts b/src/app/api/internal/web-vitals/route.ts
index 7abe172bf..84683f421 100644
--- a/src/app/api/internal/web-vitals/route.ts
+++ b/src/app/api/internal/web-vitals/route.ts
@@ -57,7 +57,13 @@ const webVitalsBodySchema = z.object({
delta: z.number().finite().optional(),
rating: z.enum(["good", "needs-improvement", "poor"]).optional(),
navigationType: z
- .enum(["navigate", "reload", "back-forward", "back-forward-cache", "prerender"])
+ .enum([
+ "navigate",
+ "reload",
+ "back-forward",
+ "back-forward-cache",
+ "prerender",
+ ])
.optional(),
});
@@ -90,11 +96,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// ≤ 7 beacons / page-load and well below a flood. 429 on hit; the
// beacon contract still accepts 4xx without retry on the client.
const ip = getClientIp(request);
- const rl = await checkRateLimit(
- `web-vitals:${ip ?? "unknown"}`,
- 60,
- 60_000,
- );
+ const rl = await checkRateLimit(`web-vitals:${ip ?? "unknown"}`, 60, 60_000);
if (!rl.allowed) {
return NextResponse.json(
{ error: "rate_limited" },
@@ -104,7 +106,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
let raw: unknown;
try {
- raw = await request.json();
+ const text = await request.text();
+ if (text.length > 64 * 1024) {
+ return NextResponse.json({ error: "payload_too_large" }, { status: 413 });
+ }
+ raw = JSON.parse(text);
} catch {
// Malformed payload — 400 so the beacon never retries. Crucially:
// we do NOT log the raw body (log-injection defence).
diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts
index e6077de71..3786b742e 100644
--- a/src/app/api/measurements/[id]/route.ts
+++ b/src/app/api/measurements/[id]/route.ts
@@ -66,7 +66,9 @@ export const PUT = apiHandler(
return apiError("Measurement not found", 404);
}
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = updateMeasurementSchema.safeParse(body);
diff --git a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts
index c761efd55..55d976bc1 100644
--- a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts
+++ b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts
@@ -173,6 +173,15 @@ describe("POST /api/measurements/bulk-delete", () => {
expect(res.status).toBe(422);
});
+ it("rejects a body over the 256 KB cap with 413 before parsing", async () => {
+ const res = await POST(
+ postReq({ ids: ["m1"], pad: "x".repeat(256 * 1024) }),
+ );
+ expect(res.status).toBe(413);
+ expect(prisma.measurement.findMany).not.toHaveBeenCalled();
+ expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
+ });
+
it("429s when the rate limit is exceeded", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
const res = await POST(postReq({ ids: ["m1"] }));
diff --git a/src/app/api/measurements/bulk-delete/route.ts b/src/app/api/measurements/bulk-delete/route.ts
index 3494bd985..60a2a85f4 100644
--- a/src/app/api/measurements/bulk-delete/route.ts
+++ b/src/app/api/measurements/bulk-delete/route.ts
@@ -59,7 +59,9 @@ async function postBulkDelete(request: NextRequest): Promise {
return apiError("Too many bulk deletions, try again later", 429);
}
- const { data: rawBody, error: jsonError } = await safeJson(request);
+ const { data: rawBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 256 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = bulkDeleteSchema.safeParse(rawBody);
diff --git a/src/app/api/measurements/by-external-ids/route.ts b/src/app/api/measurements/by-external-ids/route.ts
index 1e25aa07e..9c07d6a53 100644
--- a/src/app/api/measurements/by-external-ids/route.ts
+++ b/src/app/api/measurements/by-external-ids/route.ts
@@ -55,7 +55,9 @@ export const DELETE = apiHandler(deleteByExternalIds);
async function deleteByExternalIds(request: NextRequest): Promise {
const { user } = await requireAuth();
- const { data: rawBody, error: jsonError } = await safeJson(request);
+ const { data: rawBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 256 * 1024,
+ });
if (jsonError) return jsonError;
// Distinguish "too many entries" from "validation failed" so the iOS
diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts
index ac55d66a2..652da7bec 100644
--- a/src/app/api/measurements/route.ts
+++ b/src/app/api/measurements/route.ts
@@ -336,13 +336,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// the live `date_trunc` path so a brand-new account whose
// populator hasn't caught up still sees a correct chart on its
// first render.
- if (
- source === "rollup" &&
- aggregate === "daily" &&
- type &&
- from &&
- to
- ) {
+ if (source === "rollup" && aggregate === "daily" && type && from && to) {
const cap = Math.min(limit, BUCKET_CAP.daily);
// v1.11.1 — rollup rows are per source; collapse overlapping sources to the
// ladder-canonical reading per day before the chart consumes them, so a
@@ -422,9 +416,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// Cheap probe — one indexed range scan over
// `(user_id, type, measured_at)`. Returns the count of distinct
// calendar days the live table holds for the window.
- const liveDayCountRows = await prisma.$queryRaw<
- Array<{ days: bigint }>
- >`
+ const liveDayCountRows = await prisma.$queryRaw>`
SELECT COUNT(DISTINCT date_trunc('day', m."measured_at"))::bigint AS days
FROM measurements m
WHERE m."user_id" = ${user.id}
@@ -504,9 +496,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
type != null && CUMULATIVE_HK_TYPES.has(type as MeasurementType);
const measurements = effectiveRows.map((r) => ({
type: r.type,
- value: useSum
- ? (r.sumValue ?? r.mean * r.count)
- : r.mean,
+ value: useSum ? (r.sumValue ?? r.mean * r.count) : r.mean,
measuredAt: r.bucketStart.toISOString(),
count: r.count,
// v1.8.5 — per-day min / max so the chart can shade an
@@ -847,7 +837,9 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postMeasurement));
async function postMeasurement(request: NextRequest) {
const { user } = await requireAuth();
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
diff --git a/src/app/api/medications/[id]/api-endpoint/route.ts b/src/app/api/medications/[id]/api-endpoint/route.ts
index 420d16c4c..d7736af97 100644
--- a/src/app/api/medications/[id]/api-endpoint/route.ts
+++ b/src/app/api/medications/[id]/api-endpoint/route.ts
@@ -74,7 +74,11 @@ export const PUT = apiHandler(
let enabled = false;
try {
- const body = await request.json();
+ const raw = await request.text();
+ if (raw.length > 64 * 1024) {
+ return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
+ }
+ const body = JSON.parse(raw);
enabled = body?.enabled === true;
} catch {
return apiError("Invalid request", 422);
diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts
index 07677f6a6..a951fc643 100644
--- a/src/app/api/medications/[id]/glp1/route.ts
+++ b/src/app/api/medications/[id]/glp1/route.ts
@@ -150,7 +150,9 @@ export const POST = apiHandler(
});
}
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = glp1PostBodySchema.safeParse(body);
diff --git a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts
index 8337a3b27..cde47a0cd 100644
--- a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts
@@ -358,6 +358,106 @@ describe("PUT — v1.15.18 band re-attribution", () => {
expect((created?.scheduledFor as Date).getTime()).toBe(at(19, 0).getTime());
});
+ it("422s an edited takenAt before the medication's start date (P0-4)", async () => {
+ // Use fixed wall-clock so the schema's "not in the future / within 5
+ // years" bounds hold regardless of when the suite runs.
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date("2026-06-10T12:00:00Z"));
+ try {
+ vi.mocked(prisma.medication.findFirst).mockResolvedValue({
+ id: "m1",
+ startsOn: new Date("2026-06-01T00:00:00Z"),
+ endsOn: null,
+ oneShot: false,
+ createdAt: new Date("2026-01-01T00:00:00Z"),
+ schedules: [],
+ } as never);
+ vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
+ id: "e1",
+ userId: "user-1",
+ medicationId: "m1",
+ scheduledFor: at(7, 0),
+ takenAt: at(7, 5),
+ skipped: false,
+ } as never);
+
+ const res = await PUT(
+ putReq({ takenAt: "2026-05-15T10:00:00+02:00" }),
+ ROUTE_CTX,
+ );
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("start date");
+ // The guard fires before any write.
+ expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("does not false-reject a takenAt in the early hours of the start day (tz day-key)", async () => {
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date("2026-06-10T12:00:00Z"));
+ try {
+ // startsOn 2026-06-01; the edit is 00:30 local Berlin on the start day,
+ // which is 2026-05-31T22:30Z — a naive UTC compare would reject it.
+ vi.mocked(prisma.medication.findFirst).mockResolvedValue({
+ id: "m1",
+ startsOn: new Date("2026-06-01T00:00:00Z"),
+ endsOn: null,
+ oneShot: false,
+ createdAt: new Date("2026-01-01T00:00:00Z"),
+ schedules: [
+ {
+ id: "s1",
+ windowStart: "07:00",
+ windowEnd: "07:00",
+ daysOfWeek: null,
+ timesOfDay: ["07:00", "19:00"],
+ reminderGraceMinutes: null,
+ rrule: null,
+ rollingIntervalDays: null,
+ scheduleType: "SCHEDULED",
+ cyclicOnWeeks: null,
+ cyclicOffWeeks: null,
+ },
+ ],
+ } as never);
+ vi.mocked(prisma.medicationIntakeEvent.findFirst)
+ .mockResolvedValueOnce({
+ id: "e1",
+ userId: "user-1",
+ medicationId: "m1",
+ scheduledFor: at(7, 0),
+ takenAt: at(7, 5),
+ skipped: false,
+ } as never)
+ .mockResolvedValue(null as never);
+ vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
+ [] as never,
+ );
+ vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue(
+ {} as never,
+ );
+ vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({
+ id: "e2",
+ userId: "user-1",
+ medicationId: "m1",
+ scheduledFor: new Date("2026-05-31T22:30:00Z"),
+ takenAt: new Date("2026-05-31T22:30:00Z"),
+ skipped: false,
+ } as never);
+
+ const res = await PUT(
+ putReq({ takenAt: "2026-06-01T00:30:00+02:00" }),
+ ROUTE_CTX,
+ );
+ expect(res.status).toBe(200);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
it("422s a forceSlotInstant that is not a real slot", async () => {
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
id: "e1",
diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts
index 5a8a76f19..22996a426 100644
--- a/src/app/api/medications/[id]/intake/[eventId]/route.ts
+++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts
@@ -14,6 +14,7 @@ import { updateIntakeEventSchema } from "@/lib/validations/medication";
import { reconcileOneShotState } from "@/lib/medications/lifecycle";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
+import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz";
import {
applyCanonicalSlotWrite,
resolveForcedSlotForWrite,
@@ -39,7 +40,9 @@ export const PUT = apiHandler(
return apiError("Intake not found", 404);
}
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = updateIntakeEventSchema.safeParse(body);
@@ -76,6 +79,35 @@ export const PUT = apiHandler(
const data = parsed.data;
+ // v1.15.19 — start-date guard on an edited `takenAt` (audit P0-4). The
+ // schema bounds the instant to a plausible absolute range; this check
+ // adds the per-medication floor: a take cannot predate the day the
+ // medication starts (calendar-day comparison in the user's timezone, so
+ // a take in the early hours of the start day never false-rejects).
+ if (data.takenAt instanceof Date) {
+ const medication = await prisma.medication.findFirst({
+ where: { id, userId: user.id },
+ select: { startsOn: true },
+ });
+ if (medication?.startsOn) {
+ const takenDay = dayKeyForUserTz(data.takenAt, user.timezone);
+ const startsOnDay = medication.startsOn.toISOString().slice(0, 10);
+ if (takenDay < startsOnDay) {
+ annotate({
+ action: {
+ name: "medications.intake.event.update.before-start-date",
+ },
+ meta: { medication_id: id, event_id: eventId },
+ });
+ return apiError(
+ "takenAt is before the medication's start date",
+ 422,
+ { errorCode: "medications.intake.taken_at.before_start" },
+ );
+ }
+ }
+ }
+
// The post-edit state of the row (current values where the body omits a
// field) — drives the re-attribution decision.
const nextTakenAt =
diff --git a/src/app/api/medications/[id]/intake/bulk-delete/route.ts b/src/app/api/medications/[id]/intake/bulk-delete/route.ts
index 87bdf8c83..46732457d 100644
--- a/src/app/api/medications/[id]/intake/bulk-delete/route.ts
+++ b/src/app/api/medications/[id]/intake/bulk-delete/route.ts
@@ -77,7 +77,9 @@ export const POST = apiHandler(
});
}
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 256 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = bulkDeleteIntakeEventsSchema.safeParse(body);
if (!parsed.success) {
diff --git a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts
index 671a11a8b..b8199b8ab 100644
--- a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts
@@ -71,6 +71,11 @@ beforeEach(() => {
});
describe("POST /api/medications/[id]/intake/import — 422 multi-issue (v1.4.43 W6)", () => {
+ it("rejects a body over the 1 MB cap with 413 before parsing", async () => {
+ const res = await POST(postReq(["x".repeat(1024 * 1024)]), ROUTE_CTX);
+ expect(res.status).toBe(413);
+ });
+
it("surfaces TWO simultaneous validation errors", async () => {
// Two entries with malformed datum + malformed uhrzeit.
const res = await POST(
diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts
index d739cb365..0cff38b3f 100644
--- a/src/app/api/medications/[id]/intake/import/route.ts
+++ b/src/app/api/medications/[id]/intake/import/route.ts
@@ -37,7 +37,9 @@ export const POST = apiHandler(
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 1024 * 1024,
+ });
if (jsonError) return jsonError;
diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts
index 1373fe569..e201ebf22 100644
--- a/src/app/api/medications/[id]/intake/route.ts
+++ b/src/app/api/medications/[id]/intake/route.ts
@@ -44,7 +44,9 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = intakeSchema.safeParse({
@@ -126,9 +128,13 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
action: { name: "medication.intake.injection_site.disallowed" },
meta: { medication_id: id, site: resolution.site },
});
- return apiError("Injection site is not allowed for this medication", 422, {
- errorCode: "medications.intake.injection_site.disallowed",
- });
+ return apiError(
+ "Injection site is not allowed for this medication",
+ 422,
+ {
+ errorCode: "medications.intake.injection_site.disallowed",
+ },
+ );
}
resolvedInjectionSite = resolution.site;
}
@@ -269,33 +275,82 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
});
}
} else {
- // Unscheduled / PRN / off-slot — keep the original insert behaviour.
- [event] = await prisma.$transaction([
- prisma.medicationIntakeEvent.create({
- data: {
- userId: user.id,
- medicationId: id,
- scheduledFor: incomingScheduledFor,
- takenAt: resolvedTakenAt,
- skipped,
- source: "WEB",
- idempotencyKey: idempotencyKey ?? null,
- // v1.8.5 — site only on a resolved taken-injection write.
- ...(resolvedInjectionSite !== null && {
- injectionSite: resolvedInjectionSite,
- }),
- },
- }),
- // Reset snooze when medication is taken
- ...(!skipped
- ? [
- prisma.medication.update({
- where: { id },
- data: { snoozedUntil: null },
+ // Unscheduled / PRN / off-slot. When the client named an explicit
+ // `scheduledFor`, converge source-agnostically onto any live row that
+ // already sits on that instant (e.g. the pending REMINDER row the
+ // worker minted on a slot the band attribution did not claim) before
+ // inserting. Without the probe the insert lands a second live row for
+ // the same slot that differs only by `source` — the partial unique
+ // index carries `source` and cannot catch it — inflating the
+ // compliance rollup's scheduled count. A defaulted anchor (takenAt /
+ // now) never names a slot, so the probe is skipped on that hot path.
+ const existingSlotRow =
+ scheduledFor !== undefined
+ ? await prisma.medicationIntakeEvent.findFirst({
+ where: {
+ userId: user.id,
+ medicationId: id,
+ scheduledFor: incomingScheduledFor,
+ deletedAt: null,
+ },
+ select: { id: true },
+ })
+ : null;
+ if (existingSlotRow) {
+ const applied = await applyCanonicalSlotWrite({
+ client: prisma,
+ userId: user.id,
+ medicationId: id,
+ canonicalSlot: incomingScheduledFor,
+ takenAt: resolvedTakenAt,
+ skipped,
+ isExplicitTaken,
+ isExplicitSkip,
+ idempotencyKey: idempotencyKey ?? null,
+ createSource: "WEB",
+ injectionSite: resolvedInjectionSite,
+ });
+ event = applied.row;
+ consumedTransition = applied.consumedTransition;
+ if (!skipped && !applied.noDowngradeNoOp) {
+ await prisma.medication.update({
+ where: { id },
+ data: { snoozedUntil: null },
+ });
+ }
+ } else {
+ // Genuinely standalone. Anchor a taken write on the intake instant —
+ // the documented ad-hoc contract (`scheduledFor = takenAt`) — so an
+ // unresolvable client anchor can never park a live row exactly on a
+ // slot instant a pending REMINDER row is minted for later. A skip
+ // without a slot keeps the incoming instant (it has no takenAt).
+ [event] = await prisma.$transaction([
+ prisma.medicationIntakeEvent.create({
+ data: {
+ userId: user.id,
+ medicationId: id,
+ scheduledFor: resolvedTakenAt ?? incomingScheduledFor,
+ takenAt: resolvedTakenAt,
+ skipped,
+ source: "WEB",
+ idempotencyKey: idempotencyKey ?? null,
+ // v1.8.5 — site only on a resolved taken-injection write.
+ ...(resolvedInjectionSite !== null && {
+ injectionSite: resolvedInjectionSite,
}),
- ]
- : []),
- ]);
+ },
+ }),
+ // Reset snooze when medication is taken
+ ...(!skipped
+ ? [
+ prisma.medication.update({
+ where: { id },
+ data: { snoozedUntil: null },
+ }),
+ ]
+ : []),
+ ]);
+ }
}
// v1.4.25 W19b — pen-inventory dose decrement. Only fires for
diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts
index f51bbee3d..25b7aa3b7 100644
--- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts
+++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts
@@ -56,7 +56,9 @@ export const PATCH = apiHandler(
const existing = await loadOwnedItem(id, itemId, user.id);
if (!existing) return apiError("Inventory item not found", 404);
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = updateInventoryItemSchema.safeParse(body);
diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts
index 193931644..41b3a5ebf 100644
--- a/src/app/api/medications/[id]/inventory/route.ts
+++ b/src/app/api/medications/[id]/inventory/route.ts
@@ -79,7 +79,9 @@ export const POST = apiHandler(
});
}
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = createInventoryItemSchema.safeParse(body);
diff --git a/src/app/api/medications/[id]/phase-config/route.ts b/src/app/api/medications/[id]/phase-config/route.ts
index e4fbe243e..b8eada6c1 100644
--- a/src/app/api/medications/[id]/phase-config/route.ts
+++ b/src/app/api/medications/[id]/phase-config/route.ts
@@ -1,11 +1,7 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
-import {
- apiSuccess,
- returnAllZodIssues,
- safeJson,
-} from "@/lib/api-response";
+import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { phaseConfigSchema } from "@/lib/validations/phase-config";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { NextRequest } from "next/server";
@@ -58,7 +54,9 @@ export const PUT = apiHandler(
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = phaseConfigSchema.safeParse(body);
diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts
index 2817baf87..c200acf77 100644
--- a/src/app/api/medications/[id]/route.ts
+++ b/src/app/api/medications/[id]/route.ts
@@ -49,21 +49,22 @@ export const GET = apiHandler(
// cadences re-anchor on the latest non-skipped intake, so fetch it
// once (no-op for calendar cadences but cheap + keeps the value
// correct for rolling injections).
- const lastIntake =
- medication.schedules.some((s) => s.rollingIntervalDays !== null)
- ? await prisma.medicationIntakeEvent.findFirst({
- // v1.7.0 sync — a tombstoned intake no longer anchors the
- // rolling-interval next-due computation.
- where: {
- userId: user.id,
- medicationId: id,
- deletedAt: null,
- takenAt: { not: null },
- },
- orderBy: { takenAt: "desc" },
- select: { takenAt: true },
- })
- : null;
+ const lastIntake = medication.schedules.some(
+ (s) => s.rollingIntervalDays !== null,
+ )
+ ? await prisma.medicationIntakeEvent.findFirst({
+ // v1.7.0 sync — a tombstoned intake no longer anchors the
+ // rolling-interval next-due computation.
+ where: {
+ userId: user.id,
+ medicationId: id,
+ deletedAt: null,
+ takenAt: { not: null },
+ },
+ orderBy: { takenAt: "desc" },
+ select: { takenAt: true },
+ })
+ : null;
// v1.15.10 — slots the user has already acted on near now, so the
// next-due search skips them and advances to the next genuinely-open
// slot. Bound the read to [now-1d, now+2d] — the lookahead only needs the
@@ -136,7 +137,9 @@ export const PUT = apiHandler(
return apiError("Medication not found", 404);
}
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = updateMedicationSchema.safeParse(body);
@@ -223,8 +226,7 @@ export const PUT = apiHandler(
// Normalise endsOn for one-shot. `oneShot === true` + `startsOn`
// means the dose is the start date; endsOn auto-matches.
- const normalisedEndsOn =
- oneShot === true && startsOn ? startsOn : endsOn;
+ const normalisedEndsOn = oneShot === true && startsOn ? startsOn : endsOn;
const pausedAtPatch =
active === undefined
@@ -321,7 +323,9 @@ export const PUT = apiHandler(
// v1.15.18 — per-dose configurable on-time windows. A `schedules`
// replace re-creates the rows, so the column is re-written each
// time; absent leaves it NULL (default ±1h derivation).
- ...(s.doseWindows !== undefined && { doseWindows: s.doseWindows }),
+ ...(s.doseWindows !== undefined && {
+ doseWindows: s.doseWindows,
+ }),
};
}),
},
@@ -373,8 +377,7 @@ export const PUT = apiHandler(
await prisma.medicationSchedule.update({
where: { id: primaryScheduleGracePatch.scheduleId },
data: {
- reminderGraceMinutes:
- primaryScheduleGracePatch.reminderGraceMinutes,
+ reminderGraceMinutes: primaryScheduleGracePatch.reminderGraceMinutes,
},
});
const refreshed = await prisma.medication.findUnique({
diff --git a/src/app/api/medications/[id]/side-effects/route.ts b/src/app/api/medications/[id]/side-effects/route.ts
index 755a5645e..899fa0067 100644
--- a/src/app/api/medications/[id]/side-effects/route.ts
+++ b/src/app/api/medications/[id]/side-effects/route.ts
@@ -111,7 +111,9 @@ export const POST = apiHandler(
});
}
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = createSideEffectSchema.safeParse(body);
diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts
index 30c2a2dd4..5da4e8ce8 100644
--- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts
+++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts
@@ -18,6 +18,8 @@ vi.mock("@/lib/db", () => ({
// v1.8.2 reconcile — shared slot upsert reads + updates in place.
findMany: vi.fn(),
update: vi.fn(),
+ // v1.15.19 — resolver-null convergence probe before standalone insert.
+ findFirst: vi.fn(),
},
auditLog: { create: vi.fn() },
},
@@ -83,6 +85,13 @@ beforeEach(() => {
});
describe("POST /api/medications/intake/bulk — 422 multi-issue (v1.4.43 W6)", () => {
+ it("rejects a body over the 2 MB cap with 413 before parsing", async () => {
+ const res = await POST(
+ postReq({ entries: [], pad: "x".repeat(2 * 1024 * 1024) }),
+ );
+ expect(res.status).toBe(413);
+ });
+
it("surfaces TWO simultaneous validation errors", async () => {
const res = await POST(
postReq({
@@ -317,7 +326,10 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => {
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
- data: { updated: number; entries: Array<{ status: string; id?: string }> };
+ data: {
+ updated: number;
+ entries: Array<{ status: string; id?: string }>;
+ };
};
// The dose is APPLIED to the existing row, not silently dropped.
expect(body.data.updated).toBe(1);
@@ -328,3 +340,176 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => {
);
});
});
+
+// ────────────────────────────────────────────────────────────────────
+// v1.15.19 — resolver-null convergence (cross-source duplicate slots)
+// ────────────────────────────────────────────────────────────────────
+
+describe("POST /api/medications/intake/bulk — v1.15.19 resolver-null convergence", () => {
+ const SCHEDULED_MED = {
+ id: "med-1",
+ startsOn: null,
+ endsOn: null,
+ oneShot: false,
+ createdAt: new Date("2026-01-01T00:00:00Z"),
+ schedules: [
+ {
+ id: "s1",
+ windowStart: "07:00",
+ windowEnd: "07:00",
+ daysOfWeek: null,
+ timesOfDay: ["07:00", "19:00"],
+ reminderGraceMinutes: null,
+ rrule: null,
+ rollingIntervalDays: null,
+ scheduleType: "SCHEDULED",
+ cyclicOnWeeks: null,
+ cyclicOffWeeks: null,
+ doseWindows: null,
+ },
+ ],
+ };
+
+ beforeEach(() => {
+ // Pin the clock HOURS before the fixture slot so the taken-write
+ // future-slot guard (TAKEN_FORWARD_GRACE_MS) rejects the snap and the
+ // resolver returns null — the production shape: the reminder worker has
+ // already pre-minted the pending REMINDER row at the slot instant, the
+ // client confirms early with an explicit `scheduledFor` on that instant.
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date("2026-06-15T00:30:00.000Z"));
+ vi.mocked(prisma.medication.findMany).mockResolvedValue([
+ { id: "med-1" },
+ ] as never);
+ vi.mocked(prisma.medication.findFirst).mockResolvedValue(
+ SCHEDULED_MED as never,
+ );
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("converges an early taken-write onto the pre-minted pending REMINDER row instead of inserting a sibling", async () => {
+ // Source-agnostic probe finds the live REMINDER row on the incoming
+ // instant…
+ vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce({
+ id: "row-reminder",
+ } as never);
+ // …and the shared slot upsert re-reads + updates it in place.
+ vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
+ {
+ id: "row-reminder",
+ takenAt: null,
+ skipped: false,
+ idempotencyKey: null,
+ scheduledFor: new Date("2026-06-15T05:00:00Z"),
+ source: "REMINDER",
+ createdAt: new Date("2026-06-15T00:00:00Z"),
+ },
+ ] as never);
+ vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
+ id: "row-reminder",
+ } as never);
+
+ const res = await POST(
+ postReq({
+ entries: [
+ {
+ medicationId: "med-1",
+ scheduledFor: "2026-06-15T05:00:00.000Z",
+ takenAt: "2026-06-15T00:30:00.000Z",
+ },
+ ],
+ }),
+ );
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ data: {
+ inserted: number;
+ updated: number;
+ entries: Array<{ status: string; id?: string }>;
+ };
+ };
+ // The slot did NOT fork into a second live event.
+ expect(body.data.updated).toBe(1);
+ expect(body.data.inserted).toBe(0);
+ expect(body.data.entries[0].status).toBe("updated");
+ expect(body.data.entries[0].id).toBe("row-reminder");
+ expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
+ expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledWith(
+ expect.objectContaining({ where: { id: "row-reminder" } }),
+ );
+ });
+
+ it("anchors a genuinely standalone resolver-null take on takenAt, not on the client's slot anchor", async () => {
+ // No live row on the incoming instant — the write is ad-hoc and must
+ // anchor on the intake instant so a worker mint at the slot instant
+ // later cannot pair up with it as a cross-source duplicate.
+ vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce(
+ null as never,
+ );
+ vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValueOnce({
+ id: "row-standalone",
+ } as never);
+
+ const res = await POST(
+ postReq({
+ entries: [
+ {
+ medicationId: "med-1",
+ scheduledFor: "2026-06-15T05:00:00.000Z",
+ takenAt: "2026-06-15T00:30:00.000Z",
+ },
+ ],
+ }),
+ );
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ data: { inserted: number; entries: Array<{ status: string }> };
+ };
+ expect(body.data.inserted).toBe(1);
+ expect(body.data.entries[0].status).toBe("inserted");
+ expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ scheduledFor: new Date("2026-06-15T00:30:00.000Z"),
+ takenAt: new Date("2026-06-15T00:30:00.000Z"),
+ }),
+ }),
+ );
+ });
+
+ it("keeps the idempotencyKey replay contract: a re-submission reports duplicate, not updated", async () => {
+ // The replay pre-check fires BEFORE the convergence probe so a re-sent
+ // entry keeps its historical `duplicate` status.
+ vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValueOnce({
+ id: "row-existing",
+ } as never);
+
+ const res = await POST(
+ postReq({
+ entries: [
+ {
+ medicationId: "med-1",
+ scheduledFor: "2026-06-15T05:00:00.000Z",
+ takenAt: "2026-06-15T00:30:00.000Z",
+ idempotencyKey: "ios-key-1",
+ },
+ ],
+ }),
+ );
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ data: {
+ duplicates: number;
+ entries: Array<{ status: string; id?: string }>;
+ };
+ };
+ expect(body.data.duplicates).toBe(1);
+ expect(body.data.entries[0].status).toBe("duplicate");
+ expect(body.data.entries[0].id).toBe("row-existing");
+ expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
+ expect(prisma.medicationIntakeEvent.findFirst).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts
index 7eb9ca0e9..2bdbd06cd 100644
--- a/src/app/api/medications/intake/bulk/route.ts
+++ b/src/app/api/medications/intake/bulk/route.ts
@@ -112,7 +112,9 @@ async function postBulk(request: NextRequest): Promise {
return apiError("Too many bulk submissions, try again later", 429);
}
- const { data: rawBody, error: jsonError } = await safeJson(request);
+ const { data: rawBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 2 * 1024 * 1024,
+ });
if (jsonError) return jsonError;
if (
@@ -207,7 +209,10 @@ async function postBulk(request: NextRequest): Promise {
// touched by the batch so one rollup recompute fires per pair after
// all inserts complete. Per-row recompute would balloon a 500-entry
// batch into 500 sequential rollup hits.
- const touchedDays = new Map();
+ const touchedDays = new Map<
+ string,
+ { medicationId: string; dayKey: string }
+ >();
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
@@ -244,7 +249,10 @@ async function postBulk(request: NextRequest): Promise {
isTakenWrite: !entry.skipped && entry.takenAt !== undefined,
});
- const scheduledFor = canonicalSlot ?? incomingScheduledFor;
+ // The `scheduledFor` instant the row actually lands on — set by the
+ // branch that performs the write so the rollup recompute below keys
+ // the same day the stored row anchors to.
+ let effectiveScheduledFor = canonicalSlot ?? incomingScheduledFor;
// C2 — classify the incoming write. An offline-sync replay echoes a
// PENDING projection (no `takenAt`, `skipped:false`) for a slot the
@@ -312,28 +320,104 @@ async function postBulk(request: NextRequest): Promise {
results.push({ index: i, status: "inserted", id: applied.row.id });
}
} else {
- // Unscheduled / PRN — insert. The idempotencyKey, when supplied,
- // has a UNIQUE index; a re-submission returns the existing row via
- // P2002 → status: "duplicate".
- const row = await prisma.medicationIntakeEvent.create({
- data: {
+ // Resolver-null: PRN / off-slot / future-slot-guarded. Before the
+ // standalone insert, two guards keep the slot from forking into a
+ // second live row:
+ //
+ // 1. idempotencyKey replay pre-check — a re-submission returns the
+ // existing row as "duplicate" (previously reached via the P2002
+ // catch; the explicit probe keeps that contract now that a
+ // replay could otherwise converge as "updated" below).
+ // 2. source-agnostic convergence probe — when ANY live row already
+ // sits on the incoming instant (e.g. the pending REMINDER row
+ // the worker pre-minted on a slot the taken-write future guard
+ // refused to snap to), converge onto THAT row through the shared
+ // slot upsert instead of inserting a sibling that differs only
+ // by `source`. The partial unique index carries `source`, so it
+ // cannot catch this duplicate; the probe must.
+ if (entry.idempotencyKey) {
+ const replay = await prisma.medicationIntakeEvent.findUnique({
+ where: { idempotencyKey: entry.idempotencyKey },
+ select: { id: true },
+ });
+ if (replay) {
+ duplicates += 1;
+ results.push({ index: i, status: "duplicate", id: replay.id });
+ continue;
+ }
+ }
+ // Only an explicit client `scheduledFor` names a slot a row could
+ // already sit on; a defaulted anchor (takenAt / now) never does, so
+ // the probe is skipped on that path.
+ const existingSlotRow =
+ entry.scheduledFor !== undefined
+ ? await prisma.medicationIntakeEvent.findFirst({
+ where: {
+ userId: user.id,
+ medicationId: entry.medicationId,
+ scheduledFor: incomingScheduledFor,
+ deletedAt: null,
+ },
+ select: { id: true },
+ })
+ : null;
+ if (existingSlotRow) {
+ const applied = await applyCanonicalSlotWrite({
+ client: prisma,
userId: user.id,
medicationId: entry.medicationId,
- scheduledFor,
+ canonicalSlot: incomingScheduledFor,
takenAt: entry.takenAt ?? null,
skipped: entry.skipped,
- source: "API",
+ isExplicitTaken,
+ isExplicitSkip,
idempotencyKey: entry.idempotencyKey ?? null,
- // v1.8.5 — site only on a resolved taken-injection entry.
- ...(resolvedInjectionSite !== null && {
- injectionSite: resolvedInjectionSite,
- }),
- },
- });
- inserted += 1;
- results.push({ index: i, status: "inserted", id: row.id });
+ createSource: "API",
+ injectionSite: resolvedInjectionSite,
+ });
+ if (applied.noDowngradeNoOp) {
+ duplicates += 1;
+ results.push({ index: i, status: "duplicate", id: applied.row.id });
+ } else if (applied.outcome === "updated") {
+ updated += 1;
+ results.push({ index: i, status: "updated", id: applied.row.id });
+ } else {
+ inserted += 1;
+ results.push({ index: i, status: "inserted", id: applied.row.id });
+ }
+ } else {
+ // Genuinely standalone. Anchor the row on the intake instant —
+ // the documented ad-hoc contract (`scheduledFor = takenAt`) — so
+ // an unresolvable client anchor (a future slot instant the taken
+ // guard rejected) can never park a live row exactly on a slot the
+ // worker later mints a pending REMINDER row for. A pending echo
+ // (no takenAt) keeps the incoming instant. The idempotencyKey,
+ // when supplied, has a UNIQUE index; a racing re-submission
+ // returns the existing row via P2002 → status: "duplicate".
+ effectiveScheduledFor = entry.takenAt ?? incomingScheduledFor;
+ const row = await prisma.medicationIntakeEvent.create({
+ data: {
+ userId: user.id,
+ medicationId: entry.medicationId,
+ scheduledFor: effectiveScheduledFor,
+ takenAt: entry.takenAt ?? null,
+ skipped: entry.skipped,
+ source: "API",
+ idempotencyKey: entry.idempotencyKey ?? null,
+ // v1.8.5 — site only on a resolved taken-injection entry.
+ ...(resolvedInjectionSite !== null && {
+ injectionSite: resolvedInjectionSite,
+ }),
+ },
+ });
+ inserted += 1;
+ results.push({ index: i, status: "inserted", id: row.id });
+ }
}
- const dayKey = dayKeyForScheduledFor(scheduledFor, user.timezone);
+ const dayKey = dayKeyForScheduledFor(
+ effectiveScheduledFor,
+ user.timezone,
+ );
touchedDays.set(`${entry.medicationId}|${dayKey}`, {
medicationId: entry.medicationId,
dayKey,
diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts
index b9f35fd7e..15548a6dc 100644
--- a/src/app/api/medications/intake/route.ts
+++ b/src/app/api/medications/intake/route.ts
@@ -277,7 +277,9 @@ async function buildScheduleAnchoredComplianceBuckets(
const dayKeys: string[] = [];
const dayBounds = new Map();
for (let i = days - 1; i >= 0; i--) {
- const representative = new Date(nowMs - i * 86_400_000 - 12 * 60 * 60 * 1000);
+ const representative = new Date(
+ nowMs - i * 86_400_000 - 12 * 60 * 60 * 1000,
+ );
const { start: dayStart, end: dayEndInclusive } = getUserTodayBounds(
representative,
userTz,
@@ -297,7 +299,12 @@ async function buildScheduleAnchoredComplianceBuckets(
// Group events by medication so each med's engine context is built once.
const eventsByMed = new Map<
string,
- { scheduledFor: Date; takenAt: Date | null; skipped: boolean; autoMissed: boolean }[]
+ {
+ scheduledFor: Date;
+ takenAt: Date | null;
+ skipped: boolean;
+ autoMissed: boolean;
+ }[]
>();
for (const e of events) {
const list = eventsByMed.get(e.medicationId) ?? [];
@@ -363,7 +370,9 @@ async function buildScheduleAnchoredComplianceBuckets(
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
- const { data: body, error } = await safeJson(request);
+ const { data: body, error } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (error) return error;
const parsed = updateSchema.safeParse(body);
@@ -393,8 +402,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
- const { intakeId, status, takenAt, snoozedUntil, injectionSite, forceSlotInstant } =
- parsed.data;
+ const {
+ intakeId,
+ status,
+ takenAt,
+ snoozedUntil,
+ injectionSite,
+ forceSlotInstant,
+ } = parsed.data;
// v1.7.0 sync — a tombstoned intake 404s on a status toggle; the
// `deletedAt: null` filter refuses to mutate a soft-deleted row.
@@ -428,8 +443,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
taken: status === "taken",
deliveryForm: existing.medication.deliveryForm,
trackInjectionSites: existing.medication.trackInjectionSites,
- allowedInjectionSites:
- existing.medication.allowedInjectionSites as InjectionSiteValue[],
+ allowedInjectionSites: existing.medication
+ .allowedInjectionSites as InjectionSiteValue[],
globalExcludedInjectionSites: (userRow?.globalExcludedInjectionSites ??
[]) as InjectionSiteValue[],
});
@@ -438,9 +453,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "medication.intake.injection_site.disallowed" },
meta: { medication_id: existing.medicationId, site: resolution.site },
});
- return apiError("Injection site is not allowed for this medication", 422, {
- errorCode: "medications.intake.injection_site.disallowed",
- });
+ return apiError(
+ "Injection site is not allowed for this medication",
+ 422,
+ {
+ errorCode: "medications.intake.injection_site.disallowed",
+ },
+ );
}
resolvedInjectionSite = resolution.site;
}
diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts
index 8e4f77835..e9ec1fdf4 100644
--- a/src/app/api/medications/route.ts
+++ b/src/app/api/medications/route.ts
@@ -38,7 +38,9 @@ async function buildMedicationsList(
// today-end + 2d] — the next-due lookahead only needs the slots adjacent to
// now, and a tight window keeps the read cheap. The 60s list cache covers
// the rest.
- const resolvedWindowEnd = new Date(todayEndUtc.getTime() + 2 * 24 * 60 * 60 * 1000);
+ const resolvedWindowEnd = new Date(
+ todayEndUtc.getTime() + 2 * 24 * 60 * 60 * 1000,
+ );
const [medications, latestIntakes, todayEvents, resolvedEvents] =
await Promise.all([
@@ -174,7 +176,9 @@ export const GET = apiHandler(async () => {
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = createMedicationSchema.safeParse(body);
@@ -319,7 +323,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
rollingIntervalDays: s.rollingIntervalDays,
}),
// v1.7.0 — schedule type + cyclic weeks, field-by-field.
- ...(s.scheduleType !== undefined && { scheduleType: s.scheduleType }),
+ ...(s.scheduleType !== undefined && {
+ scheduleType: s.scheduleType,
+ }),
...(s.cyclicOnWeeks !== undefined && {
cyclicOnWeeks: s.cyclicOnWeeks,
}),
diff --git a/src/app/api/monitoring/glitchtip/route.ts b/src/app/api/monitoring/glitchtip/route.ts
index e3a720d4a..c83a7596f 100644
--- a/src/app/api/monitoring/glitchtip/route.ts
+++ b/src/app/api/monitoring/glitchtip/route.ts
@@ -30,7 +30,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
let body: unknown;
try {
- body = await request.json();
+ const raw = await request.text();
+ if (raw.length > 256 * 1024) {
+ return apiError(`Request body exceeds ${256 * 1024} bytes`, 413);
+ }
+ body = JSON.parse(raw);
} catch {
return apiError("Invalid JSON data", 422);
}
diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts
index 23b61e8ea..0bb45c30f 100644
--- a/src/app/api/mood-entries/[id]/route.ts
+++ b/src/app/api/mood-entries/[id]/route.ts
@@ -73,7 +73,9 @@ export const PUT = apiHandler(
return apiError("Mood entry not found", 404);
}
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = updateMoodEntrySchema.safeParse(body);
diff --git a/src/app/api/mood-entries/bulk-delete/route.ts b/src/app/api/mood-entries/bulk-delete/route.ts
index 9c61322f2..4f0616be3 100644
--- a/src/app/api/mood-entries/bulk-delete/route.ts
+++ b/src/app/api/mood-entries/bulk-delete/route.ts
@@ -54,7 +54,9 @@ async function postBulkDelete(request: NextRequest): Promise {
return apiError("Too many bulk deletions, try again later", 429);
}
- const { data: rawBody, error: jsonError } = await safeJson(request);
+ const { data: rawBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 256 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = bulkDeleteSchema.safeParse(rawBody);
diff --git a/src/app/api/mood-entries/bulk/__tests__/route.test.ts b/src/app/api/mood-entries/bulk/__tests__/route.test.ts
index c9ec4b489..1946d171e 100644
--- a/src/app/api/mood-entries/bulk/__tests__/route.test.ts
+++ b/src/app/api/mood-entries/bulk/__tests__/route.test.ts
@@ -101,6 +101,13 @@ beforeEach(() => {
});
describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => {
+ it("rejects a body over the 2 MB cap with 413 before parsing", async () => {
+ const res = await POST(
+ postReq({ entries: [], pad: "x".repeat(2 * 1024 * 1024) }),
+ );
+ expect(res.status).toBe(413);
+ });
+
it("surfaces TWO simultaneous validation errors", async () => {
const res = await POST(
postReq({
diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts
index 8679e847d..fddbfa944 100644
--- a/src/app/api/mood-entries/bulk/route.ts
+++ b/src/app/api/mood-entries/bulk/route.ts
@@ -78,7 +78,10 @@ const bulkEntrySchema = z.object({
*/
ratedFactors: z
.array(
- z.object({ key: z.string().max(60), rating: z.number().int().min(1).max(5) }),
+ z.object({
+ key: z.string().max(60),
+ rating: z.number().int().min(1).max(5),
+ }),
)
.max(30)
.optional(),
@@ -126,7 +129,9 @@ async function postBulk(request: NextRequest): Promise {
return apiError("Too many bulk submissions, try again later", 429);
}
- const { data: rawBody, error: jsonError } = await safeJson(request);
+ const { data: rawBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 2 * 1024 * 1024,
+ });
if (jsonError) return jsonError;
if (
diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts
index e24894ab6..655c062c3 100644
--- a/src/app/api/mood-entries/route.ts
+++ b/src/app/api/mood-entries/route.ts
@@ -130,7 +130,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
// form re-renders the sliders without a refetch.
ratedFactors: tagLinks
.filter((link) => link.moodTag.kind === "RATED" && link.rating !== null)
- .map((link) => ({ key: link.moodTag.key, rating: link.rating as number })),
+ .map((link) => ({
+ key: link.moodTag.key,
+ rating: link.rating as number,
+ })),
}));
return apiSuccess({
@@ -144,7 +147,9 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postMoodEntry));
async function postMoodEntry(request: NextRequest) {
const { user } = await requireAuth();
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = createMoodEntrySchema.safeParse(body);
diff --git a/src/app/api/mood/tags/[key]/hidden/route.ts b/src/app/api/mood/tags/[key]/hidden/route.ts
index f1929bea5..fb7dc4d6a 100644
--- a/src/app/api/mood/tags/[key]/hidden/route.ts
+++ b/src/app/api/mood/tags/[key]/hidden/route.ts
@@ -1,7 +1,12 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
-import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response";
+import {
+ apiSuccess,
+ apiError,
+ returnAllZodIssues,
+ safeJson,
+} from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { hideCatalogueTagSchema, isCustomTagKey } from "@/lib/mood/custom-tags";
@@ -29,7 +34,11 @@ export const PUT = apiHandler(
);
}
- const parsed = hideCatalogueTagSchema.safeParse(await request.json());
+ const { data: rawJsonBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
+ if (jsonError) return jsonError;
+ const parsed = hideCatalogueTagSchema.safeParse(rawJsonBody);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
// Resolve against the catalogue (user_id NULL) only.
diff --git a/src/app/api/mood/tags/custom/[key]/route.ts b/src/app/api/mood/tags/custom/[key]/route.ts
index c8dd7b9cf..697489bbf 100644
--- a/src/app/api/mood/tags/custom/[key]/route.ts
+++ b/src/app/api/mood/tags/custom/[key]/route.ts
@@ -1,7 +1,12 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
-import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response";
+import {
+ apiSuccess,
+ apiError,
+ returnAllZodIssues,
+ safeJson,
+} from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
@@ -30,7 +35,11 @@ export const PATCH = apiHandler(
const { key } = await params;
if (!isCustomTagKey(key)) return apiError("Not a custom tag", 404);
- const parsed = updateCustomTagSchema.safeParse(await request.json());
+ const { data: rawJsonBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
+ if (jsonError) return jsonError;
+ const parsed = updateCustomTagSchema.safeParse(rawJsonBody);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const owned = await prisma.moodTag.findFirst({
diff --git a/src/app/api/mood/tags/custom/route.ts b/src/app/api/mood/tags/custom/route.ts
index 2736816c8..d5cf6d30e 100644
--- a/src/app/api/mood/tags/custom/route.ts
+++ b/src/app/api/mood/tags/custom/route.ts
@@ -1,7 +1,12 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
-import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response";
+import {
+ apiSuccess,
+ apiError,
+ returnAllZodIssues,
+ safeJson,
+} from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
@@ -24,7 +29,11 @@ export const dynamic = "force-dynamic";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
- const parsed = createCustomTagSchema.safeParse(await request.json());
+ const { data: rawJsonBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
+ if (jsonError) return jsonError;
+ const parsed = createCustomTagSchema.safeParse(rawJsonBody);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const activeCount = await prisma.moodTag.count({
@@ -52,10 +61,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
sortOrder: activeCount,
userId: user.id,
},
- select: { key: true, icon: true, kind: true, scaleMin: true, scaleMax: true, inverse: true },
+ select: {
+ key: true,
+ icon: true,
+ kind: true,
+ scaleMin: true,
+ scaleMax: true,
+ inverse: true,
+ },
});
- annotate({ action: { name: "mood.tag.custom.create" }, meta: { icon: created.icon } });
+ annotate({
+ action: { name: "mood.tag.custom.create" },
+ meta: { icon: created.icon },
+ });
return apiSuccess(
{
diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts
index 4db1d5e84..9b98b2ca4 100644
--- a/src/app/api/notifications/preferences/route.ts
+++ b/src/app/api/notifications/preferences/route.ts
@@ -96,7 +96,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
let body: unknown;
try {
- body = await request.json();
+ const raw = await request.text();
+ if (raw.length > 64 * 1024) {
+ return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
+ }
+ body = JSON.parse(raw);
} catch {
return apiError("Invalid JSON data", 422);
}
diff --git a/src/app/api/notifications/status/route.ts b/src/app/api/notifications/status/route.ts
index 39085a3c5..402521ee7 100644
--- a/src/app/api/notifications/status/route.ts
+++ b/src/app/api/notifications/status/route.ts
@@ -150,7 +150,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
let body: unknown;
try {
- body = await request.json();
+ const raw = await request.text();
+ if (raw.length > 64 * 1024) {
+ return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
+ }
+ body = JSON.parse(raw);
} catch {
return apiError("Invalid JSON data", 422);
}
diff --git a/src/app/api/notifications/web-push/route.ts b/src/app/api/notifications/web-push/route.ts
index 45ca81ba5..ccc2d07ef 100644
--- a/src/app/api/notifications/web-push/route.ts
+++ b/src/app/api/notifications/web-push/route.ts
@@ -28,7 +28,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
let body: unknown;
try {
- body = await request.json();
+ const raw = await request.text();
+ if (raw.length > 64 * 1024) {
+ return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
+ }
+ body = JSON.parse(raw);
} catch {
return apiError("Invalid JSON data", 422);
}
@@ -90,7 +94,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
let body: unknown;
try {
- body = await request.json();
+ const raw = await request.text();
+ if (raw.length > 64 * 1024) {
+ return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
+ }
+ body = JSON.parse(raw);
} catch {
return apiError("Invalid JSON data", 422);
}
diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts
index bef7c65d3..a6542a530 100644
--- a/src/app/api/onboarding/complete/route.ts
+++ b/src/app/api/onboarding/complete/route.ts
@@ -21,7 +21,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "onboarding.complete" } });
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const result = z.safeParse(onboardingSchema, body);
diff --git a/src/app/api/onboarding/step/route.ts b/src/app/api/onboarding/step/route.ts
index 47a9a22f0..50e73f716 100644
--- a/src/app/api/onboarding/step/route.ts
+++ b/src/app/api/onboarding/step/route.ts
@@ -61,7 +61,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Too many onboarding writes, try again later", 429);
}
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = stepBodySchema.safeParse(body);
@@ -102,10 +104,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "onboarding.step" },
meta: { outcome: "out_of_order", current, requested: step },
});
- return apiError(
- `Step out of order — current step is ${current}`,
- 409,
- );
+ return apiError(`Step out of order — current step is ${current}`, 409);
}
const completing = step === 4;
diff --git a/src/app/api/onboarding/tour/route.ts b/src/app/api/onboarding/tour/route.ts
index a219aee23..9e9b72a4a 100644
--- a/src/app/api/onboarding/tour/route.ts
+++ b/src/app/api/onboarding/tour/route.ts
@@ -33,7 +33,9 @@ const tourBodySchema = z.object({
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = z.safeParse(tourBodySchema, body);
diff --git a/src/app/api/settings/account/route.ts b/src/app/api/settings/account/route.ts
index 7a9ef3213..d5f0d3da3 100644
--- a/src/app/api/settings/account/route.ts
+++ b/src/app/api/settings/account/route.ts
@@ -17,7 +17,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
let confirm = "";
try {
- const body = await request.json();
+ const raw = await request.text();
+ if (raw.length > 64 * 1024) {
+ return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
+ }
+ const body = JSON.parse(raw);
confirm = typeof body?.confirm === "string" ? body.confirm : "";
} catch {
return apiError("Invalid request", 422);
diff --git a/src/app/api/settings/data/route.ts b/src/app/api/settings/data/route.ts
index d2758f0cd..71235937e 100644
--- a/src/app/api/settings/data/route.ts
+++ b/src/app/api/settings/data/route.ts
@@ -15,7 +15,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
let confirm = "";
try {
- const body = await request.json();
+ const raw = await request.text();
+ if (raw.length > 64 * 1024) {
+ return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
+ }
+ const body = JSON.parse(raw);
confirm = typeof body?.confirm === "string" ? body.confirm : "";
} catch {
return apiError("Invalid request", 422);
diff --git a/src/app/api/settings/moodlog/route.ts b/src/app/api/settings/moodlog/route.ts
index 5f17e6dc0..274a757cc 100644
--- a/src/app/api/settings/moodlog/route.ts
+++ b/src/app/api/settings/moodlog/route.ts
@@ -17,7 +17,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
let body: unknown;
try {
- body = await request.json();
+ const raw = await request.text();
+ if (raw.length > 64 * 1024) {
+ return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
+ }
+ body = JSON.parse(raw);
} catch {
return apiError("Invalid JSON", 400);
}
diff --git a/src/app/api/settings/ntfy/route.ts b/src/app/api/settings/ntfy/route.ts
index 44f10c6f5..0bef16832 100644
--- a/src/app/api/settings/ntfy/route.ts
+++ b/src/app/api/settings/ntfy/route.ts
@@ -47,7 +47,9 @@ export const GET = apiHandler(async () => {
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = ntfySettingsSchema.safeParse(body);
diff --git a/src/app/api/settings/telegram/route.ts b/src/app/api/settings/telegram/route.ts
index ec99a1222..697e41d02 100644
--- a/src/app/api/settings/telegram/route.ts
+++ b/src/app/api/settings/telegram/route.ts
@@ -49,7 +49,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
});
if (!current) return apiError("User not found", 404);
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const result = z.safeParse(telegramSettingsSchema, body);
diff --git a/src/app/api/telegram/webhook/route.ts b/src/app/api/telegram/webhook/route.ts
index 03ac91845..b492fa489 100644
--- a/src/app/api/telegram/webhook/route.ts
+++ b/src/app/api/telegram/webhook/route.ts
@@ -684,7 +684,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
let update: TelegramUpdate;
try {
- update = (await request.json()) as TelegramUpdate;
+ const raw = await request.text();
+ if (raw.length > 256 * 1024) {
+ // Oversized payload — acknowledge with 200 like invalid JSON so the
+ // sender does not retry-loop the update.
+ return NextResponse.json({ status: "invalid json" }, { status: 200 });
+ }
+ update = JSON.parse(raw) as TelegramUpdate;
} catch {
return NextResponse.json({ status: "invalid json" }, { status: 200 });
}
diff --git a/src/app/api/tokens/route.ts b/src/app/api/tokens/route.ts
index c8a2a0f9b..cde3bc73a 100644
--- a/src/app/api/tokens/route.ts
+++ b/src/app/api/tokens/route.ts
@@ -52,7 +52,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("API is globally disabled", 403);
}
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = createTokenSchema.safeParse(body);
diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts
index cbd63f9a5..2134c6e84 100644
--- a/src/app/api/user/ai-provider/route.ts
+++ b/src/app/api/user/ai-provider/route.ts
@@ -46,8 +46,10 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "user.ai-provider.update" } });
- const { data: body, error } =
- await safeJson>(request);
+ const { data: body, error } = await safeJson>(
+ request,
+ { maxBytes: 64 * 1024 },
+ );
if (error) return error;
const updates: Record = {};
diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts
index 6dd2ed943..1722858a5 100644
--- a/src/app/api/user/profile/route.ts
+++ b/src/app/api/user/profile/route.ts
@@ -75,7 +75,9 @@ export const GET = apiHandler(async () => {
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
- const { data: body, error } = await safeJson(request);
+ const { data: body, error } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (error) return error;
const result = await applyProfileUpdate(user.id, body, getClientIp(request));
diff --git a/src/app/api/user/thresholds/route.ts b/src/app/api/user/thresholds/route.ts
index bf353dc92..c13eb52b0 100644
--- a/src/app/api/user/thresholds/route.ts
+++ b/src/app/api/user/thresholds/route.ts
@@ -76,7 +76,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
return apiError("Too many requests, please slow down", 429);
}
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const parsed = thresholdsUpdateSchema.safeParse(body);
diff --git a/src/app/api/version/__tests__/route.test.ts b/src/app/api/version/__tests__/route.test.ts
index bd913b466..d086f1ad6 100644
--- a/src/app/api/version/__tests__/route.test.ts
+++ b/src/app/api/version/__tests__/route.test.ts
@@ -36,7 +36,7 @@ describe("GET /api/version", () => {
const response = await (GET as unknown as () => Promise)();
const body = (await response.json()) as VersionEnvelope;
expect(body.data.version).toMatch(/^\d+\.\d+\.\d+(\.\d+)?(-[a-z0-9.-]+)?$/i);
- expect(body.data.license).toBe("AGPL-3.0");
+ expect(body.data.license).toBe("PolyForm-Noncommercial-1.0.0");
expect(body.data.repository).toContain("github.com");
expect(body.data.changelog).toContain("CHANGELOG");
expect(body.data.docs).toContain("docs.healthlog.dev");
diff --git a/src/app/api/version/route.ts b/src/app/api/version/route.ts
index c5d6029bc..3f1ffa9c9 100644
--- a/src/app/api/version/route.ts
+++ b/src/app/api/version/route.ts
@@ -54,7 +54,7 @@ export const GET = apiHandler(async () => {
version,
buildSha,
builtAt,
- license: "AGPL-3.0",
+ license: "PolyForm-Noncommercial-1.0.0",
repository: "https://github.com/MBombeck/HealthLog",
changelog: "https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md",
docs: "https://docs.healthlog.dev",
diff --git a/src/app/api/whoop/credentials/route.ts b/src/app/api/whoop/credentials/route.ts
index 645287c2f..618d87405 100644
--- a/src/app/api/whoop/credentials/route.ts
+++ b/src/app/api/whoop/credentials/route.ts
@@ -35,7 +35,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "whoop.credentials.update" } });
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const result = z.safeParse(whoopCredentialsSchema, body);
diff --git a/src/app/api/whoop/sync/route.ts b/src/app/api/whoop/sync/route.ts
index da912d4a0..17d069ad4 100644
--- a/src/app/api/whoop/sync/route.ts
+++ b/src/app/api/whoop/sync/route.ts
@@ -1,5 +1,5 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
-import { apiSuccess } from "@/lib/api-response";
+import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { syncUserWhoop } from "@/lib/whoop/sync";
import { NextRequest } from "next/server";
@@ -16,7 +16,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
let fullSync = false;
try {
- const body = await request.json();
+ const raw = await request.text();
+ // Flag-only payload — cap the parse cost (mirrors safeJson maxBytes).
+ if (raw.length > 64 * 1024) {
+ return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
+ }
+ const body = JSON.parse(raw);
fullSync = body?.fullSync === true;
} catch {
// no body provided -> default incremental sync
diff --git a/src/app/api/whoop/webhook/[token]/route.ts b/src/app/api/whoop/webhook/[token]/route.ts
index 9c6dcb691..f3e411b9d 100644
--- a/src/app/api/whoop/webhook/[token]/route.ts
+++ b/src/app/api/whoop/webhook/[token]/route.ts
@@ -27,9 +27,7 @@ interface RouteContext {
params: Promise<{ token: string }>;
}
-async function verifyTokenSegment(
- token: string | undefined,
-): Promise {
+async function verifyTokenSegment(token: string | undefined): Promise {
const expected = process.env.WHOOP_WEBHOOK_SECRET;
if (!expected) {
getEvent()?.addWarning("WHOOP_WEBHOOK_SECRET not configured");
@@ -57,6 +55,14 @@ export const POST = apiHandler(
// exactly once — `request.json()` would consume it and defeat the
// signature check.
const rawBody = await request.text();
+ if (rawBody.length > 256 * 1024) {
+ // Cap the body before the HMAC walk and JSON.parse — legitimate
+ // webhook payloads are tiny.
+ return NextResponse.json(
+ { status: "payload_too_large" },
+ { status: 413 },
+ );
+ }
const secret = process.env.WHOOP_WEBHOOK_SECRET;
if (
!secret ||
diff --git a/src/app/api/withings/credentials/route.ts b/src/app/api/withings/credentials/route.ts
index f59323a57..0bd669f86 100644
--- a/src/app/api/withings/credentials/route.ts
+++ b/src/app/api/withings/credentials/route.ts
@@ -36,7 +36,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "withings.credentials.update" } });
- const { data: body, error: jsonError } = await safeJson(request);
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
if (jsonError) return jsonError;
const result = z.safeParse(withingsCredentialsSchema, body);
diff --git a/src/app/api/withings/sync/route.ts b/src/app/api/withings/sync/route.ts
index fd93fa800..f881d62d8 100644
--- a/src/app/api/withings/sync/route.ts
+++ b/src/app/api/withings/sync/route.ts
@@ -1,5 +1,5 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
-import { apiSuccess } from "@/lib/api-response";
+import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { syncUserMeasurements } from "@/lib/withings/sync";
import { NextRequest } from "next/server";
@@ -13,7 +13,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
let fullSync = false;
try {
- const body = await request.json();
+ const raw = await request.text();
+ // Flag-only payload — cap the parse cost (mirrors safeJson maxBytes).
+ if (raw.length > 64 * 1024) {
+ return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
+ }
+ const body = JSON.parse(raw);
fullSync = body?.fullSync === true;
} catch {
// no body provided -> default incremental sync
diff --git a/src/app/privacy/__tests__/page.test.tsx b/src/app/privacy/__tests__/page.test.tsx
index d63a12d68..378bd4242 100644
--- a/src/app/privacy/__tests__/page.test.tsx
+++ b/src/app/privacy/__tests__/page.test.tsx
@@ -256,7 +256,7 @@ describe("", () => {
it("renders a discoverable footer with the version and license", () => {
const html = render();
expect(html).toContain('data-slot="privacy-footer"');
- expect(html).toContain("AGPL-3.0");
+ expect(html).toContain("PolyForm Noncommercial License 1.0.0");
expect(html).toContain(POLICY_VERSION);
});
});
diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx
index 511b9f588..6fce0785f 100644
--- a/src/app/privacy/page.tsx
+++ b/src/app/privacy/page.tsx
@@ -8,7 +8,7 @@ import Link from "next/link";
*
* 1. **Apple App Store Connect** requires a publicly reachable Privacy-
* Policy URL before the iOS native app's first submission. The URL
- * registered with ASC is `https://healthlog.bombeck.io/privacy`.
+ * registered with ASC is this route on the maintainer's instance.
* 2. **GDPR / DSGVO Art. 13 / 14 + Art. 15-22** — data-subject rights
* enumerated with concrete in-app routes the user can hit.
* 3. **EU MDR 2017/745 + MDCG 2021-24** — the boundary "HealthLog is
@@ -342,14 +342,12 @@ export default function PrivacyPage() {
>
github.com/MBombeck/HealthLog
{" "}
- veröffentlicht. Diese Erklärung beschreibt, wie die unter{" "}
+ veröffentlicht. Diese Erklärung beschreibt, wie die
+ Instanz, über die Sie diese Seite abrufen, und die
+ zugehörige iOS-Anwendung HealthLog for iOS{" "}
+ (Bundle-ID{" "}
- healthlog.bombeck.io
- {" "}
- betriebene Instanz und die zugehörige iOS-Anwendung{" "}
- HealthLog for iOS (Bundle-ID{" "}
-
- io.bombeck.healthlog
+ dev.healthlog.app
) personenbezogene Daten verarbeiten.
@@ -359,7 +357,7 @@ export default function PrivacyPage() {
Deutschland). Eigenständig betriebene, selbst-gehostete
Instanzen unterliegen der Datenschutzerklärung ihres
jeweiligen Betreibers; dieses Dokument gilt ausschließlich
- für die genannte Domain.
+ für diese Instanz.
Für Fragen oder Datenschutz-Anfragen siehe Abschnitt 11
@@ -381,14 +379,11 @@ export default function PrivacyPage() {
github.com/MBombeck/HealthLog
{" "}
under the GNU Affero General Public License v3.0. This
- policy describes how the hosted instance at{" "}
+ policy describes how the instance serving this page and
+ the companion iOS application HealthLog for iOS{" "}
+ (bundle identifier{" "}
- healthlog.bombeck.io
- {" "}
- and the companion iOS application{" "}
- HealthLog for iOS (bundle identifier{" "}
-
- io.bombeck.healthlog
+ dev.healthlog.app
) handle personal data.
@@ -397,7 +392,7 @@ export default function PrivacyPage() {
this instance (an individual based in Germany). Self-
hosted deployments controlled by a different operator are
governed by that operator's own privacy policy; this
- document applies only to the hostname above.
+ document applies only to this instance.
For questions or data-subject requests, see section 11
@@ -1211,8 +1206,8 @@ export default function PrivacyPage() {
/>
diff --git a/src/components/admin/__tests__/admin-shell.test.tsx b/src/components/admin/__tests__/admin-shell.test.tsx
index 9833c201b..fbbd28d3d 100644
--- a/src/components/admin/__tests__/admin-shell.test.tsx
+++ b/src/components/admin/__tests__/admin-shell.test.tsx
@@ -58,8 +58,8 @@ describe("", () => {
// The 13-section strip is ~1700 CSS px wide. Without
// `no-scrollbar` (defined in `globals.css`) Chromium/WebKit paint
// an always-on horizontal scrollbar at the top of every admin
- // page. Production probe at https://healthlog.bombeck.io/admin/
- // api-tokens at Pixel-5 viewport pins this as the *only* element
+ // page. A live probe of /admin/api-tokens at a Pixel-5 viewport
+ // pins this as the *only* element
// with `overflow-x:auto AND scrollWidth > clientWidth`, so the
// class is the load-bearing fix.
const html = renderShell({ active: "api-tokens" });
diff --git a/src/components/i18n/maintainership-banner.tsx b/src/components/i18n/maintainership-banner.tsx
index 2779d0783..f1ce67ed1 100644
--- a/src/components/i18n/maintainership-banner.tsx
+++ b/src/components/i18n/maintainership-banner.tsx
@@ -9,7 +9,7 @@ import { useTranslations } from "@/lib/i18n/context";
* v1.4.25 W9e — notice strip for AI-initial locales.
*
* HealthLog ships German + English as maintainer-curated translations
- * (the maintainer, Marc, owns both bodies). French, Spanish, Italian
+ * (the maintainer owns both bodies). French, Spanish, Italian
* and Polish ship as AI-initial translations — they pass the
* locale-integrity test (full key parity, no empty values, no TODOs)
* but the prose has not been hand-reviewed for register, regional
diff --git a/src/components/medications/__tests__/intake-edit-dialog-seed.test.tsx b/src/components/medications/__tests__/intake-edit-dialog-seed.test.tsx
index 3111fb0ed..51cebe654 100644
--- a/src/components/medications/__tests__/intake-edit-dialog-seed.test.tsx
+++ b/src/components/medications/__tests__/intake-edit-dialog-seed.test.tsx
@@ -17,6 +17,9 @@ import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({ t: (key: string) => key }),
+ useFormatters: () => ({
+ dateTime: (value: string | Date) => new Date(value).toISOString(),
+ }),
}));
vi.mock("@tanstack/react-query", () => ({
@@ -77,3 +80,64 @@ describe("IntakeEditDialog seed (G-1 §9 Q1)", () => {
expect(html).toBe("");
});
});
+
+describe("IntakeEditDialog guardrails (P0-4)", () => {
+ it("caps the datetime-local picker at now via the max attribute", () => {
+ const taken = new Date(Date.now() - 60 * 60 * 1000).toISOString();
+ const html = renderToStaticMarkup(
+ {}}
+ />,
+ );
+ // `YYYY-MM-DDTHH:mm` — exact value depends on render time, so pin the
+ // attribute presence + shape rather than the instant.
+ expect(html).toMatch(/max="\d{4}-\d{2}-\d{2}T\d{2}:\d{2}"/);
+ });
+
+ it("renders the scheduled-slot reference when the event carries one", () => {
+ const slot = new Date(2026, 2, 4, 8, 0).toISOString();
+ const taken = new Date(2026, 2, 4, 8, 10).toISOString();
+ const html = renderToStaticMarkup(
+ {}}
+ />,
+ );
+ expect(html).toContain("medications.detail.intake.edit.scheduledForHint");
+ // An on-time take never triggers the far-from-slot warning.
+ expect(html).not.toContain(
+ "medications.detail.intake.edit.farFromScheduledWarning",
+ );
+ });
+
+ it("shows the non-blocking far-from-slot hint when takenAt sits >48h from the slot", () => {
+ const slot = new Date(2026, 2, 4, 8, 0).toISOString();
+ // A month-off typo — exactly the P0-4 production case.
+ const taken = new Date(2026, 1, 4, 8, 0).toISOString();
+ const html = renderToStaticMarkup(
+ {}}
+ />,
+ );
+ expect(html).toContain(
+ "medications.detail.intake.edit.farFromScheduledWarning",
+ );
+ });
+
+ it("no longer renders the note field the API never persisted (LOW-10)", () => {
+ const html = renderToStaticMarkup(
+ {}}
+ />,
+ );
+ expect(html).not.toContain("intake-edit-note");
+ expect(html).not.toContain("medications.detail.intake.edit.noteLabel");
+ });
+});
diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx
index d13ddd53e..138ebe900 100644
--- a/src/components/medications/dose-history-ledger.tsx
+++ b/src/components/medications/dose-history-ledger.tsx
@@ -136,6 +136,7 @@ export function DoseHistoryLedger({
id: string;
takenAt: string | null;
skipped: boolean;
+ scheduledFor: string | null;
} | null>(null);
const [pendingDeleteId, setPendingDeleteId] = useState(null);
const [marking, setMarking] = useState(null);
@@ -384,6 +385,7 @@ function LedgerRowItem({
id: string;
takenAt: string | null;
skipped: boolean;
+ scheduledFor: string | null;
}) => void;
onDelete: (id: string) => void;
}) {
@@ -499,6 +501,7 @@ function LedgerRowItem({
id: intake.id as string,
takenAt: intake.takenAt,
skipped: intake.skipped,
+ scheduledFor: intake.scheduledFor,
})
}
className="min-h-9"
diff --git a/src/components/medications/intake-edit-dialog.tsx b/src/components/medications/intake-edit-dialog.tsx
index 45e513221..ee0e389c9 100644
--- a/src/components/medications/intake-edit-dialog.tsx
+++ b/src/components/medications/intake-edit-dialog.tsx
@@ -6,11 +6,19 @@
*
* Restores the row-level edit affordance the v1.5.4 flat-form
* retirement displaced (I-1 §15). The dialog opens from the
- * intake-history kebab; fields are `takenAt` (datetime-local),
- * `skipped` (boolean), `note` (string?). On save it PATCHes
+ * intake-history kebab; fields are `takenAt` (datetime-local) and
+ * `skipped` (boolean). On save it PATCHes
* `/api/medications/{id}/intake/{eventId}` and fires the
* medication cache bundle so the inline compliance tile + dashboard
* chart converge in the same tick.
+ *
+ * v1.15.19 (audit P0-4) — date-typo guardrails on the edit path:
+ * the picker is capped at "now" (mirrors the add dialog), the row's
+ * scheduled slot renders under the input so the user sees what they
+ * are editing against, and a non-blocking hint appears when the
+ * picked time sits more than 48h from that slot. The former free-text
+ * note field is gone — the API schema stripped it and nothing ever
+ * persisted it (audit LOW-10), so the dialog no longer pretends.
*/
import { useState } from "react";
@@ -29,8 +37,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
-import { Textarea } from "@/components/ui/textarea";
-import { useTranslations } from "@/lib/i18n/context";
+import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
export interface IntakeEditDialogProps {
@@ -40,20 +47,28 @@ export interface IntakeEditDialogProps {
id: string;
takenAt: string | null;
skipped: boolean;
- note?: string | null;
+ /** The row's slot anchor, ISO — renders as the "Geplant" reference
+ * and drives the far-from-slot hint. */
+ scheduledFor?: string | null;
} | null;
onClose: () => void;
}
+/** Past this gap between the picked time and the scheduled slot the
+ * dialog surfaces the "check the date" hint — generous enough that a
+ * genuinely late dose never nags, tight enough that a month-off typo
+ * cannot pass silently. */
+const FAR_FROM_SLOT_MS = 48 * 60 * 60 * 1000;
+
/**
- * Convert an ISO timestamp into the ``
+ * Convert an ISO timestamp (or Date) into the ``
* shape `YYYY-MM-DDTHH:mm`. Returns an empty string for null inputs.
* The string is rendered in the user's local time so the picker
* matches the dose timestamp the user remembers.
*/
-function toDateTimeLocal(iso: string | null): string {
- if (!iso) return "";
- const d = new Date(iso);
+function toDateTimeLocal(value: string | Date | null): string {
+ if (!value) return "";
+ const d = value instanceof Date ? value : new Date(value);
if (Number.isNaN(d.getTime())) return "";
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(
@@ -61,6 +76,20 @@ function toDateTimeLocal(iso: string | null): string {
)}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
+/** True when the picked local datetime sits more than 48h from the
+ * row's scheduled slot. Unparseable or absent values never warn. */
+export function isFarFromSlot(
+ takenAtLocal: string,
+ scheduledFor: string | null | undefined,
+): boolean {
+ if (!takenAtLocal || !scheduledFor) return false;
+ const taken = new Date(takenAtLocal);
+ const slot = new Date(scheduledFor);
+ if (Number.isNaN(taken.getTime()) || Number.isNaN(slot.getTime()))
+ return false;
+ return Math.abs(taken.getTime() - slot.getTime()) > FAR_FROM_SLOT_MS;
+}
+
export function IntakeEditDialog(props: IntakeEditDialogProps) {
// Re-mount the body whenever the event id flips so the form state
// initialises from the row the user clicked. The wrapper is also
@@ -75,16 +104,19 @@ function IntakeEditDialogBody({
onClose,
}: IntakeEditDialogProps) {
const { t } = useTranslations();
+ const fmt = useFormatters();
const queryClient = useQueryClient();
const [takenAt, setTakenAt] = useState(() =>
toDateTimeLocal(event?.takenAt ?? null),
);
const [skipped, setSkipped] = useState(() => event?.skipped ?? false);
- const [note, setNote] = useState(() => event?.note ?? "");
const [busy, setBusy] = useState(false);
if (!event) return null;
+ const scheduledFor = event.scheduledFor ?? null;
+ const showFarHint = !skipped && isFarFromSlot(takenAt, scheduledFor);
+
async function save() {
if (!event || busy) return;
setBusy(true);
@@ -100,9 +132,6 @@ function IntakeEditDialogBody({
} else {
body.takenAt = null;
}
- if (note.trim().length > 0) {
- body.note = note;
- }
const res = await fetch(
`/api/medications/${medicationId}/intake/${event.id}`,
{
@@ -142,9 +171,30 @@ function IntakeEditDialogBody({
id="intake-edit-taken-at"
type="datetime-local"
value={takenAt}
+ max={toDateTimeLocal(new Date())}
onChange={(e) => setTakenAt(e.target.value)}
disabled={skipped}
/>
+ {scheduledFor && (
+