feat(cycle): serve the cycle forecast as a subscribable calendar

GET /api/cycle/feed/<hlf_token>[.ics] answers any calendar app with the
record's forecast as RFC 5545: all-day events for the next few periods
over their widening windows, optionally past periods, and for a
conception goal that asks for it the next fertile window. Dates and a
title only; discreet titles and the calendar name "HealthLog" by default.
The body is built live on every fetch in the record's own zone and
language, never the request's. ETag is the content hash and a matching
If-None-Match answers 304; DTSTAMP moves only when the content does.

The feed is anonymous, so every gate is read per fetch: the auth-surface
limit before any lookup, the token's shape, the HMAC lookup of a live
feed, a per-token bucket, then the record and carrier not pending
deletion, the operator's external-access switch, cycle tracking for the
record, and for a feed a delegate minted, a live MANAGE grant covering
cycle. Every miss is the same 404.

/api/cycle/feed-subscription (status, turn on, options, turn off) and
its rotate are cookie-only through requireRecordAuth("manage", "cycle"),
with a fresh proof on turning on and rotating and a per-actor mint
bucket. The URL is returned once; the status carries the prefix, the
options, the fetch statistics as a calendar-app family and the
projection state.

The hlf_ family is registered with the path and token redactors, the
secret-shape refusal, the proxy's public paths and edge headers, the
URL-credential, sharing, recent-proof, step-up and module-gate guards,
the error-code catalogue, the activity feed and the destructive-control
registry. The cycle settings tab gains the subscription card and the
predictions panel lists the later cycles.
This commit is contained in:
Marc-André Bombeck committed 2026-11-06 18:03:37 +01:00
1 parent ac31ac40b5
commit d9a31524a2
70 files changed
+6063 -654

No files matched your search

@@ -210,6 +210,14 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
* ungated. Each carries its reason inline per the bucket-4 contract.
*/
const EXEMPT_ROUTES: ReadonlyArray<string> = [
// ── CYCLE CALENDAR FEED (#1350) ───────────────────────────────────
// Not ungated: the anonymous feed cannot answer the module's 403 (that
// would tell a prober the token is real), so `serveCycleFeed` re-reads
// `isCycleAvailableForUser` (the user toggle AND the operator switch) on
// every fetch and answers its blunt 404. `requireCycleEnabled` would also
// upsert a profile on an anonymous request. The behaviour is pinned in
// BEHAVIOURAL_GATE_TESTS below.
"src/app/api/cycle/feed/[token]/route.ts",
// ── DATA LAYER (mood) ─────────────────────────────────────────────
// Raw MoodEntry CRUD. The module gate governs whether mood SURFACES
// (nav, dashboard tile, analysis, doctor report), not whether the row
@@ -508,6 +516,11 @@ const REQUIRED_TREE_MODULE: Readonly<Record<string, ModuleKey>> = {
*/
const BEHAVIOURAL_GATE_TESTS: ReadonlyArray<{ path: string; proves: string }> =
[
{
path: "src/lib/cycle/feed/__tests__/serve.test.ts",
proves:
"the anonymous cycle calendar feed answers its blunt 404, and builds nothing, when cycle tracking is unavailable for the record",
},
{
path: "tests/integration/routes/fhir-module-gate.test.ts",
proves:
@@ -0,0 +1,115 @@
/**
* `POST /api/cycle/feed-subscription/rotate` — a new link for the record's
* calendar subscription (#1350).
*
* One transaction: the live feed is revoked and its successor minted with the
* same options, so there is never a moment with two live links or with none.
* Calendars holding the old link stop updating on their next poll. The new
* 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.
*/
import { NextRequest } from "next/server";
import {
apiHandler,
assertRecentCookieProof,
requireRecordAuth,
} from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { auditLog } from "@/lib/auth/audit";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import {
FEED_MINT_LIMIT_PER_HOUR,
FEED_MINT_WINDOW_MS,
cookieOnlyRefusal,
feedLinks,
noLiveFeed,
} from "@/lib/cycle/feed/manage";
import { FEED_SUMMARY_SELECT, toFeedSummary } from "@/lib/cycle/feed/status";
import { mintFeedToken } from "@/lib/cycle/feed/token";
import { prisma } from "@/lib/db";
import { resolveOrigin } from "@/lib/http/origin";
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");
if (auth.authMethod !== "cookie") return cookieOnlyRefusal();
const { user, actor } = auth;
await assertRecentCookieProof(actor, auth.session.id);
annotate({ action: { name: "cycle.feed.rotate" } });
if (!(await isApiGloballyEnabled())) {
return apiError("API is globally disabled", 403, {
errorCode: "cycle.feed.api_disabled",
});
}
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`cycle-feed:mint:${actor.id}`,
FEED_MINT_LIMIT_PER_HOUR,
FEED_MINT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many calendar links, try again later", 429, {
errorCode: "cycle.feed.rateLimited",
});
}
const minted = mintFeedToken();
const rotated = await prisma.$transaction(async (tx) => {
const live = await tx.cycleCalendarFeed.findFirst({
where: { userId: user.id, revokedAt: null },
select: {
id: true,
horizonCycles: true,
historyCycles: true,
includeFertile: true,
titleMode: true,
},
});
if (!live) return null;
await tx.cycleCalendarFeed.update({
where: { id: live.id },
data: { revokedAt: new Date() },
});
const created = await tx.cycleCalendarFeed.create({
data: {
userId: user.id,
createdById: actor.id,
tokenHash: minted.hash,
tokenPrefix: minted.prefix,
horizonCycles: live.horizonCycles,
historyCycles: live.historyCycles,
includeFertile: live.includeFertile,
titleMode: live.titleMode,
},
select: FEED_SUMMARY_SELECT,
});
await auditLog("cycle.feed.rotate", {
userId: user.id,
details: {
feedId: created.id,
previousFeedId: live.id,
tokenPrefix: created.tokenPrefix,
},
client: tx,
});
return created;
});
if (!rotated) return noLiveFeed();
return apiSuccess(
{
...feedLinks(resolveOrigin(request), minted.raw),
feed: toFeedSummary(rotated),
},
201,
);
});
@@ -0,0 +1,239 @@
/**
* `/api/cycle/feed-subscription` — the record's calendar subscription
* (#1350).
*
* GET → the status: token prefix, options, fetch statistics, and what
* the feed serves right now. Never the hash, never the URL.
* POST → turn it on. Mints an `hlf_` token, stores only its HMAC, and
* returns the URL exactly once.
* PATCH → change the options.
* DELETE → turn it off. The row is revoked, never deleted.
*
* 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.
*/
import { NextRequest } from "next/server";
import {
apiHandler,
assertRecentCookieProof,
requireRecordAuth,
} from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { auditLog } from "@/lib/auth/audit";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import {
FEED_MINT_LIMIT_PER_HOUR,
FEED_MINT_WINDOW_MS,
cookieOnlyRefusal,
feedExists,
feedLinks,
noLiveFeed,
} from "@/lib/cycle/feed/manage";
import {
FEED_SUMMARY_SELECT,
cycleFeedStatus,
findLiveFeed,
toFeedSummary,
} from "@/lib/cycle/feed/status";
import { mintFeedToken } from "@/lib/cycle/feed/token";
import { prisma } from "@/lib/db";
import { resolveOrigin } from "@/lib/http/origin";
import { annotate } from "@/lib/logging/context";
import { isP2002 } from "@/lib/prisma-errors";
import { checkRateLimit } from "@/lib/rate-limit";
import {
destroyedDetails,
overwriteDetails,
} from "@/lib/sharing/audit-details";
import { cycleFeedOptionsPatchSchema } from "@/lib/validations/cycle-feed";
export const GET = apiHandler(async () => {
const auth = await requireRecordAuth("manage", "cycle");
if (auth.authMethod !== "cookie") return cookieOnlyRefusal();
const { user } = auth;
annotate({ action: { name: "cycle.feed.status" } });
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
return apiSuccess(await cycleFeedStatus(user, gate.profile.goal));
});
export const POST = apiHandler(async (request: NextRequest) => {
const auth = await requireRecordAuth("manage", "cycle");
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.
await assertRecentCookieProof(actor, auth.session.id);
annotate({ action: { name: "cycle.feed.create" } });
// The operator's external-access switch covers this credential too.
if (!(await isApiGloballyEnabled())) {
return apiError("API is globally disabled", 403, {
errorCode: "cycle.feed.api_disabled",
});
}
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`cycle-feed:mint:${actor.id}`,
FEED_MINT_LIMIT_PER_HOUR,
FEED_MINT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many calendar links, try again later", 429, {
errorCode: "cycle.feed.rateLimited",
});
}
if (await findLiveFeed(user.id)) return feedExists();
const minted = mintFeedToken();
let created;
try {
created = await prisma.cycleCalendarFeed.create({
data: {
userId: user.id,
createdById: actor.id,
tokenHash: minted.hash,
tokenPrefix: minted.prefix,
},
select: FEED_SUMMARY_SELECT,
});
} catch (err) {
// A second mint racing this one lost to the one-live-feed index.
if (isP2002(err)) return feedExists();
throw err;
}
await auditLog("cycle.feed.create", {
userId: user.id,
details: { feedId: created.id, tokenPrefix: created.tokenPrefix },
});
return apiSuccess(
{
...feedLinks(resolveOrigin(request), minted.raw),
feed: toFeedSummary(created),
},
201,
);
});
export const PATCH = apiHandler(async (request: NextRequest) => {
const auth = await requireRecordAuth("manage", "cycle");
if (auth.authMethod !== "cookie") return cookieOnlyRefusal();
const { user } = auth;
annotate({ action: { name: "cycle.feed.update" } });
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const parsed = await parseJsonBody(request, cycleFeedOptionsPatchSchema, {
maxBytes: 4 * 1024,
});
if (!parsed.ok) return parsed.error;
const input = parsed.data;
// The fertile window is a conception-goal option and nothing else. Refused
// here rather than stored and ignored, so a later goal change cannot switch
// on something the person never chose under that goal.
if (
input.includeFertile === true &&
gate.profile.goal !== "TRYING_TO_CONCEIVE"
) {
return apiError(
"The fertile window is only offered when trying to conceive",
422,
{
errorCode: "cycle.feed.fertile_goal",
},
);
}
const live = await findLiveFeed(user.id);
if (!live) return noLiveFeed();
const data = {
...(input.horizonCycles !== undefined
? { horizonCycles: input.horizonCycles }
: {}),
...(input.historyCycles !== undefined
? { historyCycles: input.historyCycles }
: {}),
...(input.includeFertile !== undefined
? { includeFertile: input.includeFertile }
: {}),
...(input.titleMode !== undefined ? { titleMode: input.titleMode } : {}),
};
const updated = await prisma.cycleCalendarFeed.update({
where: { id: live.id },
data,
select: FEED_SUMMARY_SELECT,
});
await auditLog("cycle.feed.update", {
userId: user.id,
details: {
feedId: live.id,
...overwriteDetails({
before: {
horizonCycles: live.horizonCycles,
historyCycles: live.historyCycles,
includeFertile: live.includeFertile,
titleMode: live.titleMode,
},
after: {
horizonCycles: updated.horizonCycles,
historyCycles: updated.historyCycles,
includeFertile: updated.includeFertile,
titleMode: updated.titleMode,
},
}),
},
});
return apiSuccess(await cycleFeedStatus(user, gate.profile.goal));
});
export const DELETE = apiHandler(async () => {
const auth = await requireRecordAuth("manage", "cycle");
if (auth.authMethod !== "cookie") return cookieOnlyRefusal();
const { user } = auth;
annotate({ action: { name: "cycle.feed.revoke" } });
const live = await findLiveFeed(user.id);
if (!live) return noLiveFeed();
await prisma.cycleCalendarFeed.update({
where: { id: live.id },
data: { revokedAt: new Date() },
});
// The link stops working on the provider's next poll. What the provider
// already copied is beyond reach, which the settings card says before the
// link is ever made.
await auditLog("cycle.feed.revoke", {
userId: user.id,
details: {
feedId: live.id,
...destroyedDetails({
model: "CycleCalendarFeed",
id: live.id,
label: live.tokenPrefix,
effectiveAt: live.createdAt,
}),
},
});
return apiSuccess({ revoked: true });
});
+25
View File
@@ -0,0 +1,25 @@
/**
* `GET /api/cycle/feed/<hlf_token>[.ics]` — the subscribable cycle forecast
* (#1350).
*
* Anonymous by design: a calendar provider polls this URL from its own
* servers, so the `hlf_` path segment is the only credential. Rate-limited
* before the lookup, one blunt 404 for every miss, every gate re-read on each
* fetch. The pipeline and its reasoning live in `serveCycleFeed`; this file
* only binds it to the route.
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { serveCycleFeed } from "@/lib/cycle/feed/serve";
type RouteParams = { params: Promise<{ token: string }> };
export const dynamic = "force-dynamic";
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { token } = await params;
return serveCycleFeed(request, token);
},
);
+1 -17
View File
@@ -23,6 +23,7 @@ import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { resolveOrigin } from "@/lib/http/origin";
import {
createShareLinkSchema,
SHARE_LINK_FORBIDDEN_LEAVES,
@@ -41,23 +42,6 @@ import {
} from "@/lib/clinician-share/passphrase";
import type { Prisma } from "@/generated/prisma/client";
/**
* The absolute origin to build the public share URL from. Prefers the
* forwarded host/proto a reverse proxy sets (the documented self-hosting path
* fronts the app with Caddy/Traefik/Nginx), falling back to the request URL's
* own origin. The QR payload carries the passphrase in the FRAGMENT only.
*/
function resolveOrigin(request: NextRequest): string {
const proto =
request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() ||
new URL(request.url).protocol.replace(":", "");
const host =
request.headers.get("x-forwarded-host")?.split(",")[0]?.trim() ||
request.headers.get("host") ||
new URL(request.url).host;
return `${proto}://${host}`;
}
/**
* The row selection every owner-facing projection reads. `_count.documents`
* surfaces the size of the frozen document set without ever loading the ids