mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
docs(sharing): the booster needs two sections, and the guide promised it at one level
The write-level row listed "a vaccination dose and the booster reminder it suggests" three paragraphs under a section table that puts the vaccination record in Health background. Read together, that tells a self-hoster a health-background WRITE grant can plan a booster — which the route beside it now refuses, because the reminder it mints lives under Readings. The row lists the dose alone, and the exceptions paragraph, which is where a reader looks for exactly this, carries the two-section case and says the dose logs either way. With it, the small things the same pass turned up: The cycle-insights fixture asserted a property it could not show. Its docblock claims the cycle-native answer survives the fence, and it seeded no symptom at all, so `symptomPatterns` was empty in every leg and a regression that emptied it along with the foreign columns would have passed green. One catalogue symptom on the luteal days, asserted on the owner leg and on the cycle-only one. The immunization rows matched on a bare `role="button"`, which passes vacuously the day the card becomes a real button and fails for the wrong reason if anything else in the subtree grows one. They carry the same stable marker the visit card took. `/api/cycle/calendar` reads the same wrist-temperature channel the crosstab does and is deliberately not fenced, and it was the only one of the pair with nothing saying why: no value from it reaches the response. The encrypted export's new 403 documented the switch refusal and not the narrow-scope one it answers first, unlike the two settings operations beside it. Both sheets say at the top that their Save and Delete are gated at the caller, so a third mount reads the constraint before it violates it. And one docblock line ran past the wrap.
This commit is contained in:
1 parent
681e69a723
commit
83b7cd223a
10 files changed
+111
-18
No files matched your search
@@ -1765,10 +1765,15 @@ paths:
|
||||
format: binary
|
||||
"401": *a1
|
||||
"403":
|
||||
description: "Refused: the request was made while the session is acting on another account (`meta.errorCode` =
|
||||
description: >-
|
||||
The Bearer token carries a narrow scope. The encrypted archive needs a cookie session or a full-access
|
||||
token; the refusal happens before any second-factor proof is looked at.
|
||||
|
||||
|
||||
Refused: the request was made while the session is acting on another account (`meta.errorCode` =
|
||||
`sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it
|
||||
refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any
|
||||
level opens it."
|
||||
level opens it.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
|
||||
@@ -84,11 +84,11 @@ Three levels, fixed when the invitation is written. Raising one means
|
||||
revoking and inviting again, because widening a grant in place would carry
|
||||
a consent the other person never gave.
|
||||
|
||||
| Level | Adds |
|
||||
| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Read | Read what the grant opens, and change nothing. |
|
||||
| Write | Add entries: a reading, a lab result, an analyte, an illness entry, a side effect, a medication, marking a dose taken or skipped, a visit, a practice, a vaccination dose and the booster reminder it suggests. Editing, deleting and restoring stay with you. |
|
||||
| Manage | Change and remove entries, including ones the owner wrote; record the health background; read the insights generated from the record. Always the entire record. |
|
||||
| Level | Adds |
|
||||
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Read | Read what the grant opens, and change nothing. |
|
||||
| Write | Add entries: a reading, a lab result, an analyte, an illness entry, a side effect, a medication, marking a dose taken or skipped, a visit, a practice, and a vaccination dose. Editing, deleting and restoring stay with you. |
|
||||
| Manage | Change and remove entries, including ones the owner wrote; record the health background; read the insights generated from the record. Always the entire record. |
|
||||
|
||||
Some entries are creates only **manage** reaches, and the split runs by
|
||||
entry rather than by section: an allergy, a family-history entry, a mood
|
||||
@@ -96,6 +96,13 @@ entry, a screener, a cycle day, a water entry and a preventive-care
|
||||
reminder are all created at manage, in sections a write grant can
|
||||
otherwise add to. Nothing in the document vault is writable at any level.
|
||||
|
||||
One entry needs two sections rather than a higher level. Planning the
|
||||
booster a vaccination suggests files it as a preventive-care reminder,
|
||||
which lives under Readings, so a grant that opens Health background alone
|
||||
logs the dose and is refused the booster. Logging the dose is unaffected
|
||||
either way, and the app does not offer the booster to a grant that cannot
|
||||
plan it.
|
||||
|
||||
Offering **manage** asks the owner for a fresh second factor if they have
|
||||
one enrolled, which makes it a browser-only act either way: a Bearer
|
||||
caller is refused before anything else happens and told to use a browser.
|
||||
|
||||
@@ -111,6 +111,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// Apple Watch wrist/skin temperature feeds the temperature-trend
|
||||
// ovulation layer. Read the WRIST_TEMPERATURE measurements as nightly
|
||||
// values; the engine derives the trailing-mean deviation itself.
|
||||
//
|
||||
// This is a measurements read on a route declared under `cycle`, and it
|
||||
// needs no section predicate for one reason: no value from it reaches the
|
||||
// response. It goes into `predictCycle` and what comes out the other side
|
||||
// is a shifted date. The crosstab next door reads the same channel and IS
|
||||
// fenced, because there the values are the answer.
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
|
||||
@@ -714,11 +714,15 @@ describe("the immunization log's add, edit and delete", () => {
|
||||
});
|
||||
};
|
||||
|
||||
/** The row carries the edit affordance only while it opens something. */
|
||||
const rowIsTappable = (html: string) =>
|
||||
html.includes('data-slot-open="vaccination-row-open"');
|
||||
|
||||
it("offers all three in the caller's own record", () => {
|
||||
const html = render(OWN_RECORD, <VaccinationsView />, seedList);
|
||||
expect(html).toContain('data-slot="vaccination-add"');
|
||||
// The row opens the edit sheet, which is where the delete lives.
|
||||
expect(html).toContain('role="button"');
|
||||
expect(rowIsTappable(html)).toBe(true);
|
||||
});
|
||||
|
||||
it("offers a read-only delegate none of them", () => {
|
||||
@@ -730,7 +734,7 @@ describe("the immunization log's add, edit and delete", () => {
|
||||
seedList,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="vaccination-add"');
|
||||
expect(html).not.toContain('role="button"');
|
||||
expect(rowIsTappable(html)).toBe(false);
|
||||
});
|
||||
|
||||
it("offers a WRITE delegate the add and not the row", () => {
|
||||
@@ -740,8 +744,8 @@ describe("the immunization log's add, edit and delete", () => {
|
||||
seedList,
|
||||
);
|
||||
expect(html).toContain('data-slot="vaccination-add"');
|
||||
expect(html, "editing and deleting a dose are MANAGE").not.toContain(
|
||||
'role="button"',
|
||||
expect(rowIsTappable(html), "editing and deleting a dose are MANAGE").toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -752,7 +756,7 @@ describe("the immunization log's add, edit and delete", () => {
|
||||
seedList,
|
||||
);
|
||||
expect(html).toContain('data-slot="vaccination-add"');
|
||||
expect(html).toContain('role="button"');
|
||||
expect(rowIsTappable(html)).toBe(true);
|
||||
});
|
||||
|
||||
it("offers a MANAGE delegate outside the section neither", () => {
|
||||
@@ -764,7 +768,7 @@ describe("the immunization log's add, edit and delete", () => {
|
||||
seedList,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="vaccination-add"');
|
||||
expect(html).not.toContain('role="button"');
|
||||
expect(rowIsTappable(html)).toBe(false);
|
||||
});
|
||||
|
||||
it("offers the empty state's add on the same rule", () => {
|
||||
|
||||
@@ -20,6 +20,16 @@
|
||||
* saved and a plain sentence saying the checkup stayed open. That is reported
|
||||
* rather than skipped in silence: somebody who believes a checkup was closed
|
||||
* will not look at it again.
|
||||
*
|
||||
* Capability note: this sheet carries no capability check of its own, by
|
||||
* decision. Save is a create on one mount and an edit on the other, and Delete
|
||||
* exists only in edit mode — so the question belongs to whoever opens it.
|
||||
* `VisitsSection` hands it a record only under `canManageDomain("profile")`,
|
||||
* and the two mounts that can only create (`EpisodeVisitsCard`,
|
||||
* `VorsorgeSection`) pass `encounter={null}` behind their own write check. A
|
||||
* THIRD mount that hands this sheet a record without asking the manage
|
||||
* question reintroduces an ungated Delete, and nothing here will fail. Ask at
|
||||
* the caller, or move the check in here for everyone.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
@@ -58,9 +58,11 @@ type CaptureKind = "measurement" | "medication" | "mood";
|
||||
* offered whenever the grant could write anywhere — and that answer is the
|
||||
* grant's LEVEL with no scope term at all. A WRITE grant scoped to `["labs"]`
|
||||
* read as true and got a weight form the server refuses; one scoped to
|
||||
* `["documents"]`, which takes no delegated write at any level, got both. `writableDomains` is already the level × scope × route-table
|
||||
* intersection, so asking it per section answers both cases and the mood
|
||||
* entry's MANAGE-only create as well.
|
||||
* `["documents"]`, which takes no delegated write at any level, got both.
|
||||
*
|
||||
* `writableDomains` is already the level × scope × route-table intersection,
|
||||
* so asking it per section answers both cases and the mood entry's
|
||||
* MANAGE-only create as well.
|
||||
*/
|
||||
const CAPTURE_KIND_DOMAIN: Readonly<Record<CaptureKind, ShareDomain>> = {
|
||||
measurement: "measurements",
|
||||
|
||||
@@ -157,6 +157,10 @@ function DoseRow({
|
||||
<Card
|
||||
className="hover:bg-muted/40 gap-0 transition-colors"
|
||||
data-slot="vaccination-row"
|
||||
// A second, stable marker for the state rather than for the row: the
|
||||
// `role` is markup that could legitimately change, and a guard matching
|
||||
// on it would pass the day a real `<button>` replaced the card.
|
||||
data-slot-open={onEdit ? "vaccination-row-open" : undefined}
|
||||
data-vaccination-id={record.id}
|
||||
onClick={onEdit ? () => onEdit(record) : undefined}
|
||||
role={onEdit ? "button" : undefined}
|
||||
|
||||
@@ -10,6 +10,13 @@
|
||||
* On a successful create the caller may raise the booster-mint prompt; this
|
||||
* sheet completes and closes regardless of what the person does with it. The
|
||||
* mint is an offer, never a step.
|
||||
*
|
||||
* Capability note: the same posture as the visit sheet. No check here; Delete
|
||||
* renders only in edit mode, and the one mount that can reach edit mode
|
||||
* (`VaccinationsView`) asks `canManageDomain("profile")` before handing over a
|
||||
* record, while its create path asks `canWriteDomain("profile")`. A second
|
||||
* mount that skips either question reintroduces an ungated control with
|
||||
* nothing failing.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
@@ -310,7 +310,9 @@ export const exportPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description: SHARING_NOT_PERMITTED_DESCRIPTION,
|
||||
description:
|
||||
"The Bearer token carries a narrow scope. The encrypted archive needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.\n\n" +
|
||||
SHARING_NOT_PERMITTED_DESCRIPTION,
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
|
||||
@@ -165,6 +165,29 @@ async function seedRecord() {
|
||||
today,
|
||||
);
|
||||
|
||||
// One catalogue symptom, logged on enough phase-labelled days to clear the
|
||||
// pattern floor. Without it `symptomPatterns` is empty in every leg and the
|
||||
// "the cycle-native answer still arrives" property could not be shown even
|
||||
// by a leg that asserted it.
|
||||
//
|
||||
// Upserted rather than created: the symptom catalogue is seeded by migration
|
||||
// and survives the per-test truncation, so a plain create passes on the
|
||||
// first leg and collides on the second.
|
||||
const category = await prisma.cycleSymptomCategory.upsert({
|
||||
where: { key: "cycle-insights-cat" },
|
||||
update: {},
|
||||
create: { key: "cycle-insights-cat", labelKey: "cycle.category.test" },
|
||||
});
|
||||
const symptom = await prisma.cycleSymptom.upsert({
|
||||
where: { key: "cycle-insights-cramps" },
|
||||
update: {},
|
||||
create: {
|
||||
categoryId: category.id,
|
||||
key: "cycle-insights-cramps",
|
||||
labelKey: "cycle.symptom.cramps",
|
||||
},
|
||||
});
|
||||
|
||||
let luteal = 0;
|
||||
let follicular = 0;
|
||||
for (const [day, phase] of phaseByDay) {
|
||||
@@ -195,6 +218,18 @@ async function seedRecord() {
|
||||
tz: "UTC",
|
||||
},
|
||||
});
|
||||
// Cycle-native data, logged on the luteal days only so the pattern has a
|
||||
// dominant phase to report.
|
||||
if (high) {
|
||||
await prisma.cycleDayLog.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
date: day,
|
||||
tz: "UTC",
|
||||
symptomLinks: { create: [{ symptomId: symptom.id }] },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
@@ -211,7 +246,7 @@ interface InsightsBody {
|
||||
rows: Array<{ metricKey: string }>;
|
||||
headline: { metricKey: string } | null;
|
||||
lagged: { discovered: unknown[] };
|
||||
symptomPatterns: unknown[];
|
||||
symptomPatterns: Array<{ symptomKey: string; topPhase: string }>;
|
||||
}
|
||||
|
||||
async function readInsights(): Promise<InsightsBody> {
|
||||
@@ -243,6 +278,10 @@ describe("cycle insights fence the sections it reads across", () => {
|
||||
"weight",
|
||||
);
|
||||
expect(keys, "and the mood contrast beside it").toContain("mood");
|
||||
expect(
|
||||
body.symptomPatterns.map((row) => row.symptomKey),
|
||||
"and the cycle-native patterns the fence must not touch",
|
||||
).toContain("cycle-insights-cramps");
|
||||
});
|
||||
|
||||
it("gives an entire-record delegate the same figures", async () => {
|
||||
@@ -267,6 +306,13 @@ describe("cycle insights fence the sections it reads across", () => {
|
||||
body.lagged.discovered,
|
||||
"the lagged matrix is built from the same two sections",
|
||||
).toHaveLength(0);
|
||||
// The other half of the property, and the one a blanket refusal would
|
||||
// break: the symptom-by-phase patterns are cycle data and the grant opens
|
||||
// cycle, so they arrive in full.
|
||||
expect(
|
||||
body.symptomPatterns.map((row) => row.symptomKey),
|
||||
"the cycle-native answer still arrives",
|
||||
).toContain("cycle-insights-cramps");
|
||||
});
|
||||
|
||||
it("gives a cycle+mind delegate the mood figure and not the weight", async () => {
|
||||
|
||||
Reference in new issue
Block a user