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

+5 -24
View File
@@ -7,6 +7,7 @@ import {
RETIRED_ROUTE_STATUS,
} from "@/lib/http/retired-routes";
import { setBaselineSecurityHeaders } from "@/lib/http/proxy-bypass-routes";
import { setPathCredentialHeaders } from "@/lib/http/path-credential-headers";
import { DEMO_REFUSAL_BODY } from "@/lib/demo-refusal";
import { resolveRequestId } from "@/lib/http/request-id";
import { redactSecrets } from "@/lib/logging/redact";
@@ -49,6 +50,8 @@ export const PUBLIC_PATHS = [
// token in the path is the whole credential, verified by the route after
// its rate limit. Trailing slash: admits only `/action/<token>`.
"/api/notifications/action/",
// #1350 — the calendar feed; the `hlf_` path token is the credential.
"/api/cycle/feed/",
"/api/monitoring/",
"/api/withings/webhook",
// v1.11.0 — WHOOP webhook (`recovery.updated` / `sleep.updated` /
@@ -691,30 +694,8 @@ export function proxy(request: NextRequest) {
"camera=(), microphone=(), geolocation=()",
);
// v1.11.0 — the public clinician share view at `/c/<token>`
// is a scoped health record authenticated by an unguessable bearer token
// in the path. Defend it at the edge regardless of what the RSC emits:
// - `Cache-Control: no-store` so no shared proxy / CDN ever retains a
// scoped record (the page is `force-dynamic`, but that governs Next's
// cache, not a downstream intermediary).
// - `X-Robots-Tag: noindex, nofollow` as the header peer of the page's
// `robots` meta — a crawler that never parses the document still obeys
// the header, and the token must never reach a search index.
// - `Referrer-Policy: no-referrer` so the token-bearing URL is not
// leaked in the `Referer` of any outbound navigation from the page.
//
// v1.17.0 — `/invite/<hlv_token>` (iOS #16) carries an equally sensitive
// secret in the path, so it earns the same edge defence even though it is
// a pure server redirect: no shared proxy / CDN may cache it and no
// crawler may index the token-bearing URL.
if (pathname.startsWith("/c/") || pathname.startsWith("/invite/")) {
response.headers.set(
"Cache-Control",
"no-store, no-cache, must-revalidate",
);
response.headers.set("X-Robots-Tag", "noindex, nofollow");
response.headers.set("Referrer-Policy", "no-referrer");
}
// Paths whose credential is the URL itself (share view, invite, feed).
setPathCredentialHeaders(pathname, response.headers);
// COOP isolates this BrowsingContextGroup from cross-origin popups,
// closing the Spectre-class side-channel surface a stray
// `window.opener` reference would otherwise carry. CORP narrows the