Stop a delegate deferring somebody else's medication reminders

Found by an adversarial pass over the release candidate before it was tagged,
and it is the reason the tag waited.

`POST /api/medications/intake` is admitted for delegates because a caregiver
standing next to the patient has to be able to say the tablet went down. It is
an update, though, and the same body can carry two other things. Snoozing
writes `snoozedUntil` on the OWNER's medication row, and the reminder cron
skips that medication for as long as the stamp is in the future. The field
takes any future instant. So somebody with write access could stop another
person's medication reminders for years, and three things made it quiet: the
owner is deliberately not notified of a delegated snooze, the activity feed
renders the whole family as "marked a dose", and the only accurate trace was a
line in the admin audit log.

The second was flipping a dose the owner had already recorded — taken to
skipped and back, which rewrites their compliance history and refunds
inventory. The consent screen promises that changing what is already there
stays with the owner.

Both refuse for a delegate now and stay open to the owner, who is the only
person either was ever for. A refused attempt is filed under the owner and gets
its own sentence in the activity panel rather than borrowing the one for
marking a dose, because folding them together is what made the gap dangerous.

Two more from the same pass:

The consent notice named allergies among the things a delegate may add. That
left the admitted set before this release shipped, and it said so in six
languages on the one screen where somebody decides who may write into their
health record. It also omitted half of what IS admitted. The copy now matches
the frozen list, and the test asserts the withdrawal rather than only the
presence of the rest.

The banner told a WRITE delegate nothing at all — the mode line rendered only
in the read case, guarded by a comment stating that write grants did not exist
yet. This is the release that made them exist. It matters that the banner say
it, because the rule that a refused control is absent rather than disabled
rests on the banner naming the mode globally.

And a third instance of the substitution this release already fixed twice: the
BMI chart divided the owner's weights by the height of whoever was looking.
Inside a shared record it draws nothing now rather than a number belonging to
two different people.
This commit is contained in:
Marc-André Bombeck committed 2026-08-04 12:30:01 +02:00
1 parent 7ff420477c
commit 69343c8820
15 files changed
+260 -32

No files matched your search

+17 -1
View File
@@ -2,6 +2,7 @@
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import {
@@ -684,8 +685,23 @@ export function HealthChart({
// every chart card.
const viewportWidth = useViewportWidth();
// v1.36.1 — `useAuth()` answers about the person at the keyboard, and under a
// switch that is the delegate. Dividing the OWNER's weights by the
// DELEGATE's height squared produces a plausible-looking BMI that is simply
// somebody else's, shaded by fixed category bands, on a page that names the
// owner. The same substitution was fixed for the reference bands in this
// release; this is the same mistake one component over, and a wrong health
// figure is worse than an absent one.
//
// Inside a shared record the chart therefore draws no BMI at all rather than
// a wrong one. Publishing the owner's height to every client that can read
// their weights is a bigger decision than this fix, and it belongs with the
// per-module scope work rather than here.
const { inSharedRecord } = useRecordCapabilities();
const bmiDivisor =
valueMode === "bmi" && user?.heightCm ? (user.heightCm / 100) ** 2 : null;
valueMode === "bmi" && !inSharedRecord && user?.heightCm
? (user.heightCm / 100) ** 2
: null;
// v1.4.28 FB-D2 (R1.2 H0) — derive a bounded date window from the
// active range selector so the chart fetches only what it will
+13 -7
View File
@@ -75,13 +75,19 @@ export function SharedRecordBanner() {
<span className="text-foreground font-medium">
{t("recordSharing.banner.viewing", { name })}
</span>{" "}
{/* Resolved server-side. `canWrite` is false for every grant in v1, and
the day it is not, this line stops claiming otherwise on its own. */}
{!active.canWrite && (
<span className="text-muted-foreground">
{t("recordSharing.banner.readOnly")}
</span>
)}
{/* Resolved server-side, and it says something at BOTH levels now.
v1.36.1 is the release that made `canWrite` true, and until this
line changed the write case rendered nothing at all: a delegate who
could add to somebody's health record was told only whose record it
was. That matters more than the read case, not less, and the rest of
the product leans on it — a control the record refuses is absent
rather than disabled, on the stated grounds that the banner names
the mode globally. It has to actually do that. */}
<span className="text-muted-foreground">
{active.canWrite
? t("recordSharing.banner.canAdd")
: t("recordSharing.banner.readOnly")}
</span>
</p>
<button
type="button"
@@ -89,7 +89,16 @@ describe("offering a level", () => {
const html = render(<GrantInviteCard />);
// The thing a person reading "write access" gets wrong.
expect(html).toContain("cannot edit or delete anything");
expect(html).toContain("marking a dose as taken");
expect(html).toContain("marking a dose taken or skipped");
// v1.36.1 — the copy named allergies, which left the admitted set before
// this shipped, and it did so on the one screen where somebody decides who
// may write into their health record. Assert the withdrawal too, not only
// the presence of the rest: a consent notice that over-promises is worse
// than one that is merely incomplete.
expect(html).not.toContain("allergies");
// Deferring a reminder is the capability the same request could reach and
// delegation does not cover; the notice has to say so.
expect(html).toContain("cannot defer one of your reminders");
// And the narrow option still says it is narrow.
expect(html).toContain("They cannot add or change anything.");
});