fix(cycle): keep the calendar feed to the owner and a managed profile's guardian

A calendar feed is a standing credential whose contents end up in a
third party's calendar cloud. Putting a record's cycle dates there is
the decision of the person they are about, or of the guardian of
somebody who cannot make it, and no delegate of an adult's record,
MANAGE included.

The subscription routes resolve through requireGuardianAuth instead of
requireRecordAuth("manage", "cycle"): the owner on their own record, or
a MANAGE grant on a record carrying the managed-profile marker. Every
verb follows, the revoke too. The feed re-reads the marker on each fetch
as well as the guardian's grant, so a row minted for somebody else on an
adult's record is a 404 whatever grant stands behind it. The settings
card is offered only where the routes answer.

The routes move from the delegable and MANAGE literals to the guardian
list, and the MANAGE handler inventory is back to 93.
This commit is contained in:
Marc-André Bombeck committed 2026-11-06 18:03:37 +01:00
1 parent e8ef590af7
commit 3b3ef5c0a1
18 files changed
+227 -171

No files matched your search

+9 -7
View File
@@ -790,11 +790,12 @@ paths:
description: "Anonymous: a calendar provider polls this URL, so the raw `hlf_` token in the path (optionally followed by
`.ics`) is the ONLY credential. Rate-limited per source address before any lookup and per token after it. Every
gate is re-read on each fetch: the record and the minting account are not pending deletion, the operator's
external-access switch is on, cycle tracking is available, and a feed minted by a delegate still has a live
MANAGE grant over the cycle section. Every miss (malformed, unknown, revoked, switched off, deleted, lapsed
grant) is the same 404. The body is RFC 5545: all-day events, dates only, discreet titles by default, the next
few periods with widening bands (self-capped), optionally past periods and, for TRYING_TO_CONCEIVE, the next
fertile window. `ETag` is the content hash; a matching `If-None-Match` answers 304."
external-access switch is on, cycle tracking is available, and a feed minted by a managed profile's guardian
still sits on a managed profile the guardian holds a live MANAGE grant over. Every miss (malformed, unknown,
revoked, switched off, deleted, lapsed grant) is the same 404. The body is RFC 5545: all-day events, dates only,
discreet titles by default, the next few periods with widening bands (self-capped), optionally past periods and,
for TRYING_TO_CONCEIVE, the next fertile window. `ETag` is the content hash; a matching `If-None-Match` answers
304."
security: []
parameters:
- in: path
@@ -847,8 +848,9 @@ paths:
generic auth gate answers it before the route's own checks run. Otherwise: Calendar subscriptions are
managed over a browser session only: any Bearer token gets `cycle.feed.cookie_only`. Cycle tracking switched
off for the record answers `cycle.disabled`; the operator's external-access switch answers
`cycle.feed.api_disabled` on turning it on and on rotating. Reached by the record's owner and by a MANAGE
delegate whose grant covers the cycle section, never by a READ or WRITE delegate.
`cycle.feed.api_disabled` on turning it on and on rotating. Reached by the record's owner on their own
record and by the guardian of a managed profile; a delegate on an adult's record is refused at every grant
level, MANAGE included.
Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`).
+5 -3
View File
@@ -13,9 +13,11 @@ dates on its servers, and turning the subscription off later does not remove
copies it already holds. HealthLog asks you to confirm it is you (a password or
passkey) and then shows the link once. Copy it, or use **Open in calendar app**.
Only the record's owner, or someone who manages it with a MANAGE grant that
covers cycle data, can turn it on. It is managed on the web; the iOS app shows
where to find it.
Only you can turn it on for your own record. For a managed profile (a child's
record, for instance), its guardian can. Someone you have shared your record
with cannot, whatever level of access you gave them: the feed puts your dates
in their calendar provider's cloud, and that is your decision alone. It is
managed on the web; the iOS app shows where to find it.
## Adding it to your calendar
@@ -9,7 +9,7 @@
-- here only.
--
-- Both foreign keys cascade: deleting the record ends its feed, and deleting
-- the carrier who minted it does too, so a delegate's account going away
-- the carrier who minted it does too, so a guardian's account going away
-- cannot leave a link behind that nobody can revoke.
--
-- One DO block, so the file is one statement that rolls back whole and the
+5 -4
View File
@@ -1168,8 +1168,8 @@ model User {
cycleSymptomsCustom CycleSymptom[] @relation("UserCycleSymptoms")
// #1350 — the subscribable cycle forecast. `cycleCalendarFeeds` is the
// record the feed serves; `cycleCalendarFeedsMinted` is the carrier who
// minted it, which is the owner on their own record and a MANAGE delegate
// on a record they manage.
// minted it, which is the owner on their own record and the guardian on a
// managed profile.
cycleCalendarFeeds CycleCalendarFeed[] @relation("CycleCalendarFeedSubject")
cycleCalendarFeedsMinted CycleCalendarFeed[] @relation("CycleCalendarFeedCreator")
@@ -8360,8 +8360,9 @@ model CycleCalendarFeed {
id String @id @default(cuid())
/// The record whose forecast the feed serves.
userId String @map("user_id")
/// Who minted it: the owner, or a MANAGE delegate. A carrier's feed is
/// re-checked against their live grant on every fetch.
/// Who minted it: the owner, or the guardian of a managed profile. A
/// guardian's feed is re-checked against their live MANAGE grant and the
/// managed-profile marker on every fetch.
createdById String @map("created_by_id")
/// HMAC-SHA256 of the raw `hlf_` token.
@@ -98,7 +98,7 @@ const TOKEN_ARM_COOKIE_PROOF_ROUTES = [
].sort();
/**
* Routes that resolve through `requireRecordAuth` and are cookie-only: they
* Routes that resolve through `requireGuardianAuth` and are cookie-only: they
* refuse a Bearer caller outright, then take the cookie arm. The cycle
* calendar feed's mint and rotate (#1350) hand out a standing URL credential,
* so no token may reach them and the session needs a fresh proof.
@@ -163,7 +163,7 @@ describe("the recent-proof gate", () => {
it("a cookie-only record route refuses a token before it asks", () => {
for (const rel of RECORD_COOKIE_ONLY_PROOF_ROUTES) {
const src = read(rel);
expect(importedAs(rel, "requireRecordAuth"), rel).toHaveLength(1);
expect(importedAs(rel, "requireGuardianAuth"), rel).toHaveLength(1);
// Every proof call sits after a Bearer refusal in the same handler.
const proofs = [...src.matchAll(/await assertRecentCookieProof\(/g)];
expect(proofs.length, rel).toBeGreaterThan(0);
+10 -23
View File
@@ -1403,14 +1403,6 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "cycle",
why: "Setting a period boundary, which re-anchors the neighbouring cycles. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
},
"app/api/cycle/feed-subscription/route.ts": {
domain: "cycle",
why: "The record's calendar subscription (#1350): its status, turning it on, its options and turning it off. Reached only through this module's MANAGE arm, and only over a cookie; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
},
"app/api/cycle/feed-subscription/rotate/route.ts": {
domain: "cycle",
why: "A new link for the record's calendar subscription, the old one revoked in the same transaction (#1350). Reached only through this module's MANAGE arm, and only over a cookie; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
},
"app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts": {
domain: "medications",
why: "Editing and deleting an era; the delete is hard, so C3 carries its dates and dose. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
@@ -1887,16 +1879,6 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
conditions: ["C4"],
why: "Setting a period boundary, which re-anchors the neighbouring cycles. C4 on the dates it moves.",
},
"app/api/cycle/feed-subscription/route.ts": {
domain: "cycle",
conditions: ["C1", "C3", "C4"],
why: "The record's calendar subscription (#1350), the one credential a MANAGE delegate may mint. It extends no reach: the feed serves dates the grant already lets the manager read, and every fetch re-reads the manager's live MANAGE grant over the cycle section, so revoking or narrowing the grant ends the link on the provider's next poll, and a handover claim revokes it outright. Cookie-only with a fresh proof on the mint, so a Bearer token cannot leave one behind. C1 on the mint bucket, C3 on the revoke (the credential is unusable after it), C4 on the options.",
},
"app/api/cycle/feed-subscription/rotate/route.ts": {
domain: "cycle",
conditions: ["C1"],
why: "Replacing the subscription's link in one transaction (#1350). Same argument as the subscription route: no reach beyond the grant, re-checked per fetch, cookie-only with a fresh proof. C1 on the shared mint bucket.",
},
"app/api/cycle/symptoms/custom/route.ts": {
domain: "cycle",
conditions: ["C1"],
@@ -2172,6 +2154,10 @@ const GUARDIAN_ROUTES: Record<string, string> = {
"Starts an OAuth connect for the managed record a guardian administers. It returns the consent URL and the state names the managed record; the callback re-checks the browser's live MANAGE grant before any token lands. Refused on an adult record by the marker, at every grant level.",
"app/api/record-settings/integrations/[provider]/disconnect/route.ts":
"Disconnects a provider from the managed record a guardian administers, through the same disconnect the owner runs. Imported data stays. Refused on an adult record by the marker, at every grant level.",
"app/api/cycle/feed-subscription/route.ts":
"The record's cycle calendar subscription (#1350): status, turning it on, its options, turning it off. A standing credential whose contents land in a third party's calendar cloud, so only the person the dates are about or the guardian of a managed profile may hold one; refused on an adult record at every grant level, MANAGE included. Cookie-only, fresh proof on the mint; the feed re-reads the grant and the marker on every fetch and a handover claim revokes it.",
"app/api/cycle/feed-subscription/rotate/route.ts":
"A new link for the record's cycle calendar subscription, the old one revoked in the same transaction (#1350). Same argument as the subscription route: owner or managed-profile guardian only, cookie-only, fresh proof.",
};
/**
@@ -2314,11 +2300,12 @@ const ACTOR_ROUTES: Record<string, string> = {
* manage literal: it was refused under a switch while the medication's PUT,
* which used to carry the end, admitted a manager. 272 -> 274.
*
* The cycle calendar subscription (#1350) and its rotate join the record list
* and the manage literal: the owner and a MANAGE delegate over the cycle
* section may turn it on, change it, rotate it and turn it off. 276 -> 280.
* The cycle calendar subscription (#1350) and its rotate join the guardian
* list: a standing credential to a third-party calendar is the owner's to
* make, or a managed profile's guardian's, never an adult record's delegate's.
* 276 -> 278.
*/
const FROZEN_ENTRY_COUNT = 280;
const FROZEN_ENTRY_COUNT = 278;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -3096,7 +3083,7 @@ describe("(g) the MANAGE route set is frozen", () => {
it("keeps the admitted mutation inventory complete and discoverable", () => {
expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(97);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(93);
const expected = ADMITTED_MUTATING_HANDLERS.map(
({ handlerModule, action, level }) =>
@@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => {
});
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(97);
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(93);
expect(
new Set(
ADMITTED_MUTATING_HANDLERS.map(
@@ -8,15 +8,15 @@
* URL is returned exactly once.
*
* Same gates as turning the subscription on: cookie session only, the owner or
* a MANAGE delegate over the cycle section, a fresh proof, the operator's
* external-access switch, and the per-actor mint bucket.
* the guardian of a managed profile (`requireGuardianAuth`), a fresh proof,
* the operator's external-access switch, and the per-actor mint bucket.
*/
import { NextRequest } from "next/server";
import {
apiHandler,
assertRecentCookieProof,
requireRecordAuth,
requireGuardianAuth,
} from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { isApiGloballyEnabled } from "@/lib/app-settings";
@@ -37,7 +37,7 @@ import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
export const POST = apiHandler(async (request: NextRequest) => {
const auth = await requireRecordAuth("manage", "cycle");
const auth = await requireGuardianAuth();
if (auth.authMethod !== "cookie") return cookieOnlyRefusal();
const { user, actor } = auth;
await assertRecentCookieProof(actor, auth.session.id);
+15 -10
View File
@@ -12,18 +12,23 @@
* A separate namespace from the feed itself (`/api/cycle/feed/<token>`), so
* neither the path redactor nor the public-path prefix can ever catch it.
*
* Auth: cookie session only, through `requireRecordAuth("manage", "cycle")`:
* the owner, or a MANAGE delegate whose grant covers the cycle section. A
* READ or WRITE delegate is refused. Turning it on also needs a fresh proof,
* because the link outlives the session that made it. A delegate's feed is
* re-checked against their grant on every fetch, and a handover claim ends it.
* Auth: cookie session only, through `requireGuardianAuth()`: the record's
* owner on their own record, or the guardian of a managed profile (a MANAGE
* grant on a record that carries the managed-profile marker). A delegate on
* an adult's record is refused at every grant level, MANAGE included: the
* feed is a standing credential whose contents end up in a third party's
* calendar cloud, and only the person the data is about, or the guardian of
* somebody who cannot decide for themselves, may put them there. Turning it
* on and rotating also need a fresh proof. A guardian's feed is re-checked
* against the live grant and the marker on every fetch, and a handover claim
* ends it.
*/
import { NextRequest } from "next/server";
import {
apiHandler,
assertRecentCookieProof,
requireRecordAuth,
requireGuardianAuth,
} from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
@@ -57,7 +62,7 @@ import {
import { cycleFeedOptionsPatchSchema } from "@/lib/validations/cycle-feed";
export const GET = apiHandler(async () => {
const auth = await requireRecordAuth("manage", "cycle");
const auth = await requireGuardianAuth();
if (auth.authMethod !== "cookie") return cookieOnlyRefusal();
const { user } = auth;
annotate({ action: { name: "cycle.feed.status" } });
@@ -69,7 +74,7 @@ export const GET = apiHandler(async () => {
});
export const POST = apiHandler(async (request: NextRequest) => {
const auth = await requireRecordAuth("manage", "cycle");
const auth = await requireGuardianAuth();
if (auth.authMethod !== "cookie") return cookieOnlyRefusal();
const { user, actor } = auth;
// The proof is the person at the keyboard, so it is the actor's session.
@@ -131,7 +136,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
export const PATCH = apiHandler(async (request: NextRequest) => {
const auth = await requireRecordAuth("manage", "cycle");
const auth = await requireGuardianAuth();
if (auth.authMethod !== "cookie") return cookieOnlyRefusal();
const { user } = auth;
annotate({ action: { name: "cycle.feed.update" } });
@@ -207,7 +212,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
});
export const DELETE = apiHandler(async () => {
const auth = await requireRecordAuth("manage", "cycle");
const auth = await requireGuardianAuth();
if (auth.authMethod !== "cookie") return cookieOnlyRefusal();
const { user } = auth;
annotate({ action: { name: "cycle.feed.revoke" } });
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const caps = vi.hoisted(() => ({
value: { inSharedRecord: false, recordKind: "self", level: null } as {
inSharedRecord: boolean;
recordKind: string;
level: string | null;
},
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => caps.value,
}));
vi.mock("../cycle-settings", () => ({
CycleSettings: () => <div data-slot="cycle-settings-form" />,
}));
vi.mock("../cycle-calendar-feed-card", () => ({
CycleCalendarFeedCard: () => <div data-slot="cycle-calendar-feed" />,
}));
import { CycleSettingsTab } from "../cycle-settings-tab";
import type { CycleProfileDTO } from "../types";
const PROFILE = { updatedAt: "2026-11-01T00:00:00.000Z" } as CycleProfileDTO;
function render(value: typeof caps.value) {
caps.value = value;
return renderToStaticMarkup(<CycleSettingsTab profile={PROFILE} />);
}
describe("<CycleSettingsTab> calendar subscription (#1350)", () => {
it("offers it on one's own record", () => {
expect(
render({ inSharedRecord: false, recordKind: "self", level: null }),
).toContain("cycle-calendar-feed");
});
it("offers it to the guardian of a managed profile", () => {
expect(
render({ inSharedRecord: true, recordKind: "managed", level: "manage" }),
).toContain("cycle-calendar-feed");
});
it("does not offer it to a MANAGE delegate on an adult's record", () => {
const html = render({
inSharedRecord: true,
recordKind: "shared",
level: "manage",
});
expect(html).toContain("cycle-settings-form");
expect(html).not.toContain("cycle-calendar-feed");
});
});
+11 -1
View File
@@ -1,5 +1,7 @@
"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { CycleCalendarFeedCard } from "./cycle-calendar-feed-card";
import { CycleSettings } from "./cycle-settings";
import type { CycleProfileDTO } from "./types";
@@ -8,12 +10,20 @@ import type { CycleProfileDTO } from "./types";
* The cycle page's settings tab: the profile form, then the calendar
* subscription (#1350). The form remounts on a profile refetch (`key`), the
* subscription card keeps its own state, including a link it just showed.
*
* The card is the owner's on their own record, and a guardian's on a managed
* profile they manage: the routes behind it (`requireGuardianAuth`) refuse a
* delegate on an adult's record at every level, so the card is not offered
* there rather than opening onto a refusal.
*/
export function CycleSettingsTab({ profile }: { profile: CycleProfileDTO }) {
const { inSharedRecord, recordKind, level } = useRecordCapabilities();
const mayHoldFeed =
!inSharedRecord || (recordKind === "managed" && level === "manage");
return (
<div className="space-y-4">
<CycleSettings key={profile.updatedAt} profile={profile} />
<CycleCalendarFeedCard />
{mayHoldFeed ? <CycleCalendarFeedCard /> : null}
</div>
);
}
+27 -2
View File
@@ -83,6 +83,7 @@ beforeEach(() => {
id: "subject",
timezone: "Europe/Berlin",
locale: "en",
managedProfileAt: null,
});
gates.api.mockResolvedValue(true);
gates.cycle.mockResolvedValue(true);
@@ -187,12 +188,18 @@ describe("serveCycleFeed", () => {
expect(built.build).not.toHaveBeenCalled();
});
describe("a feed a delegate minted", () => {
describe("a feed a guardian minted on a managed profile", () => {
beforeEach(() => {
db.cycleCalendarFeed.findFirst.mockResolvedValue({
...FEED_ROW,
createdById: "manager",
});
db.user.findFirst.mockResolvedValue({
id: "subject",
timezone: "Europe/Berlin",
locale: "en",
managedProfileAt: new Date("2026-01-01"),
});
});
const grant = (overrides: Record<string, unknown> = {}) => ({
@@ -227,13 +234,31 @@ describe("serveCycleFeed", () => {
await expectBlunt404(await fetchFeed());
});
it("is a blunt 404 once the profile is an adult's account", async () => {
// A live MANAGE grant is not enough: only a guardian of a managed
// profile may hold a feed on somebody else's record.
gates.grant.mockResolvedValue(grant());
db.user.findFirst.mockResolvedValue({
id: "subject",
timezone: null,
locale: null,
managedProfileAt: null,
});
await expectBlunt404(await fetchFeed());
});
it("is a blunt 404 once the manager's account is gone", async () => {
gates.grant.mockResolvedValue(grant());
db.user.findFirst.mockImplementation(
async (args: { where: { id: string } }) =>
args.where.id === "manager"
? null
: { id: "subject", timezone: null, locale: null },
: {
id: "subject",
timezone: null,
locale: null,
managedProfileAt: new Date("2026-01-01"),
},
);
await expectBlunt404(await fetchFeed());
});
+3 -3
View File
@@ -4,9 +4,9 @@
* Management is cookie-only. Minting hands out a standing URL credential, so
* it needs the person at the keyboard (a fresh proof on the session) and is
* not reachable from a Bearer token at all; the native app shows "set up on
* the web" instead. The routes resolve through `requireRecordAuth("manage",
* "cycle")`, so the owner and a MANAGE delegate over the cycle section reach
* them and a READ or WRITE delegate does not.
* the web" instead. The routes resolve through `requireGuardianAuth()`, so
* the owner and the guardian of a managed profile reach them, and every
* delegate on an adult's record (MANAGE included) does not.
*/
import { apiError } from "@/lib/api-response";
+14 -3
View File
@@ -17,8 +17,11 @@
* 5. The gates, all read live and none of them through a session: the
* record and the carrier are not pending deletion, the operator's
* external-access switch is on, cycle tracking is available for the
* record (user toggle AND operator module switch), and a feed minted by
* a delegate still has a live MANAGE grant that covers the cycle section.
* record (user toggle AND operator module switch), and a feed a
* guardian minted still sits on a managed profile the guardian holds a
* live MANAGE grant over. Only a guardian of a managed profile can mint
* for somebody else (`requireGuardianAuth`); the marker is re-read here
* so a feed can never outlive the profile becoming an adult's account.
*
* Every miss is the same 404 with the same bytes: unknown, malformed,
* revoked, switched off, deleted, or a grant that has lapsed. A prober learns
@@ -123,7 +126,12 @@ async function feedStillAllowed(feed: {
const [subject, apiOn, cycleOn] = await Promise.all([
prisma.user.findFirst({
where: activeAccount({ id: feed.userId }),
select: { id: true, timezone: true, locale: true },
select: {
id: true,
timezone: true,
locale: true,
managedProfileAt: true,
},
}),
isApiGloballyEnabled(),
isCycleAvailableForUser(feed.userId),
@@ -133,6 +141,9 @@ async function feedStillAllowed(feed: {
if (!cycleOn) return { ok: false, reason: "cycle_disabled" };
if (feed.createdById !== feed.userId) {
if (subject.managedProfileAt === null) {
return { ok: false, reason: "not_managed" };
}
const [carrier, grant] = await Promise.all([
prisma.user.findFirst({
where: activeAccount({ id: feed.createdById }),
+2 -2
View File
@@ -114,7 +114,7 @@ const feedOptionsRequest = cycleFeedOptionsPatchSchema.meta({
});
const MANAGEMENT_REFUSALS = recordRefusal(
"Calendar subscriptions are managed over a browser session only: any Bearer token gets `cycle.feed.cookie_only`. Cycle tracking switched off for the record answers `cycle.disabled`; the operator's external-access switch answers `cycle.feed.api_disabled` on turning it on and on rotating. Reached by the record's owner and by a MANAGE delegate whose grant covers the cycle section, never by a READ or WRITE delegate.",
"Calendar subscriptions are managed over a browser session only: any Bearer token gets `cycle.feed.cookie_only`. Cycle tracking switched off for the record answers `cycle.disabled`; the operator's external-access switch answers `cycle.feed.api_disabled` on turning it on and on rotating. Reached by the record's owner on their own record and by the guardian of a managed profile; a delegate on an adult's record is refused at every grant level, MANAGE included.",
);
const REPROOF_401 = {
@@ -145,7 +145,7 @@ export const cycleFeedPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Cycle"],
summary: "The subscribable cycle forecast as iCalendar (public, #1350)",
description:
"Anonymous: a calendar provider polls this URL, so the raw `hlf_` token in the path (optionally followed by `.ics`) is the ONLY credential. Rate-limited per source address before any lookup and per token after it. Every gate is re-read on each fetch: the record and the minting account are not pending deletion, the operator's external-access switch is on, cycle tracking is available, and a feed minted by a delegate still has a live MANAGE grant over the cycle section. Every miss (malformed, unknown, revoked, switched off, deleted, lapsed grant) is the same 404. The body is RFC 5545: all-day events, dates only, discreet titles by default, the next few periods with widening bands (self-capped), optionally past periods and, for TRYING_TO_CONCEIVE, the next fertile window. `ETag` is the content hash; a matching `If-None-Match` answers 304.",
"Anonymous: a calendar provider polls this URL, so the raw `hlf_` token in the path (optionally followed by `.ics`) is the ONLY credential. Rate-limited per source address before any lookup and per token after it. Every gate is re-read on each fetch: the record and the minting account are not pending deletion, the operator's external-access switch is on, cycle tracking is available, and a feed minted by a managed profile's guardian still sits on a managed profile the guardian holds a live MANAGE grant over. Every miss (malformed, unknown, revoked, switched off, deleted, lapsed grant) is the same 404. The body is RFC 5545: all-day events, dates only, discreet titles by default, the next few periods with widening bands (self-capped), optionally past periods and, for TRYING_TO_CONCEIVE, the next fertile window. `ETag` is the content hash; a matching `If-None-Match` answers 304.",
security: [],
requestParams: {
path: z.object({
-32
View File
@@ -754,38 +754,6 @@ export const ADMITTED_MUTATING_HANDLERS = [
"manage",
"create",
),
handler(
"app/api/cycle/feed-subscription/route.ts",
"/api/cycle/feed-subscription",
"POST",
"cycle",
"manage",
"create",
),
handler(
"app/api/cycle/feed-subscription/route.ts",
"/api/cycle/feed-subscription",
"PATCH",
"cycle",
"manage",
"update",
),
handler(
"app/api/cycle/feed-subscription/route.ts",
"/api/cycle/feed-subscription",
"DELETE",
"cycle",
"manage",
"delete",
),
handler(
"app/api/cycle/feed-subscription/rotate/route.ts",
"/api/cycle/feed-subscription/rotate",
"POST",
"cycle",
"manage",
"create",
),
handler(
"app/api/medications/categories/route.ts",
"/api/medications/categories",
+62 -1
View File
@@ -289,6 +289,67 @@ describe("turning the subscription on", () => {
expect(await db().cycleCalendarFeed.count()).toBe(0);
});
it("refuses a MANAGE delegate on an adult's record, for every verb", async () => {
const owner = await makeUser("owner");
const manager = await makeUser("manager");
// The owner's own feed exists, so a refused DELETE has something to miss.
await mint(owner.id);
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
const invited = await inviteGrant({
grantorId: owner.id,
granteeId: manager.id,
access: "MANAGE",
scope: null,
});
await acceptGrant({ grantId: invited.id, granteeId: manager.id });
const session = await signIn(manager.id);
await switchSessionTo(session.id, owner.id);
for (const res of [
await manage("GET"),
await manage("POST"),
await manage("PATCH", { horizonCycles: 6 }),
await manage("DELETE"),
await rotate(),
]) {
expect(res.status).toBe(403);
}
const feeds = await db().cycleCalendarFeed.findMany({
where: { userId: owner.id },
});
expect(feeds).toHaveLength(1);
expect(feeds[0].revokedAt).toBeNull();
expect(feeds[0].horizonCycles).toBe(3);
expect(feeds[0].createdById).toBe(owner.id);
});
it("serves no feed a delegate holds on an adult's record, even with MANAGE", async () => {
// Written directly, as a row from before the narrowing would look: the
// fetch path refuses it on the missing managed-profile marker alone.
const owner = await makeUser("owner");
const manager = await makeUser("manager");
await seedCycles(owner.id);
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
const invited = await inviteGrant({
grantorId: owner.id,
granteeId: manager.id,
access: "MANAGE",
scope: null,
});
await acceptGrant({ grantId: invited.id, granteeId: manager.id });
const { mintFeedToken } = await import("@/lib/cycle/feed/token");
const minted = mintFeedToken();
await db().cycleCalendarFeed.create({
data: {
userId: owner.id,
createdById: manager.id,
tokenHash: minted.hash,
tokenPrefix: minted.prefix,
},
});
await expect404(await fetchFeed(`${minted.raw}.ics`));
});
it("refuses the fertile window outside TRYING_TO_CONCEIVE", async () => {
const owner = await makeUser("owner");
await mint(owner.id);
@@ -432,7 +493,7 @@ describe("fetching the feed", () => {
expect(throttled).toBe(true);
});
it("dies with the carrier's MANAGE grant, and a handover claim revokes it", async () => {
it("lets a managed profile's guardian hold one, until the grant or the handover ends it", async () => {
const profile = await makeUser("profile", { managedProfileAt: new Date() });
const guardian = await makeUser("guardian");
await seedCycles(profile.id);
@@ -280,7 +280,7 @@ const ROUTE_IMPORTS = import.meta.glob<Record<string, unknown>>(
describe("the complete MANAGE handler matrix", () => {
it("starts with a non-empty, exact handler inventory", () => {
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(97);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(93);
});
});
@@ -2678,75 +2678,6 @@ proveManageRoute("POST /api/cycle/symptoms/custom", {
})) === 1,
});
/** A live calendar feed on the record, minted as the owner would (#1350). */
async function makeCycleFeed(ownerId: string, prefix = "hlf_matr") {
await enableCycle(ownerId);
return getPrismaClient().cycleCalendarFeed.create({
data: {
userId: ownerId,
createdById: ownerId,
tokenHash: `matrix-${ownerId}-${prefix}`,
tokenPrefix: prefix,
},
});
}
async function liveCycleFeeds(ownerId: string) {
return getPrismaClient().cycleCalendarFeed.findMany({
where: { userId: ownerId, revokedAt: null },
});
}
proveManageRoute("POST /api/cycle/feed-subscription", {
route: "/api/cycle/feed-subscription",
prepare: async (ownerId) => ({ ownerId: await enableCycle(ownerId) }),
ok: 201,
auditAction: "cycle.feed.create",
applied: async ({ ownerId }) => (await liveCycleFeeds(ownerId)).length === 1,
});
proveManageRoute("PATCH /api/cycle/feed-subscription", {
route: "/api/cycle/feed-subscription",
action: "PATCH",
prepare: async (ownerId) => {
await makeCycleFeed(ownerId);
return { ownerId };
},
body: () => ({ horizonCycles: 6, titleMode: "DESCRIPTIVE" }),
ok: 200,
auditAction: "cycle.feed.update",
applied: async ({ ownerId }) => {
const [feed] = await liveCycleFeeds(ownerId);
return feed?.horizonCycles === 6 && feed.titleMode === "DESCRIPTIVE";
},
});
proveManageRoute("DELETE /api/cycle/feed-subscription", {
route: "/api/cycle/feed-subscription",
action: "DELETE",
prepare: async (ownerId) => {
await makeCycleFeed(ownerId);
return { ownerId };
},
ok: 200,
auditAction: "cycle.feed.revoke",
applied: async ({ ownerId }) => (await liveCycleFeeds(ownerId)).length === 0,
});
proveManageRoute("POST /api/cycle/feed-subscription/rotate", {
route: "/api/cycle/feed-subscription/rotate",
prepare: async (ownerId) => ({
ownerId,
previous: (await makeCycleFeed(ownerId)).id,
}),
ok: 201,
auditAction: "cycle.feed.rotate",
applied: async ({ ownerId, previous }) => {
const live = await liveCycleFeeds(ownerId);
return live.length === 1 && live[0].id !== previous;
},
});
proveManageRoute("POST /api/medications/categories", {
route: "/api/medications/categories",
prepare: async (ownerId) => ({ ownerId }),
@@ -3089,7 +3020,7 @@ describe("the conditions the admissions were granted on", () => {
describe("the complete MANAGE handler matrix", () => {
it("registers one strict actor-and-effect driver for every admission", () => {
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
expect(STRICT_DRIVER_KEYS.size).toBe(97);
expect(STRICT_DRIVER_KEYS.size).toBe(93);
expect([...STRICT_DRIVER_KEYS].sort()).toEqual(expected);
});
});
@@ -3101,6 +3032,6 @@ describe("the complete MANAGE handler matrix", () => {
afterAll(() => {
if (EFFECT_CASES_STARTED.size !== STRICT_DRIVER_KEYS.size) return;
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
expect(REAL_EFFECT_KEYS.size).toBe(97);
expect(REAL_EFFECT_KEYS.size).toBe(93);
expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected);
});