mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
1 parent
ac31ac40b5
commit
d9a31524a2
70 files changed
+6063
-654
No files matched your search
@@ -42,7 +42,7 @@
|
||||
"lib/dashboard-layout.ts": 976,
|
||||
"lib/dashboard/snapshot.ts": 1550,
|
||||
"lib/day/records.ts": 1174,
|
||||
"lib/export/backup-plan.ts": 853,
|
||||
"lib/export/backup-plan.ts": 838,
|
||||
"lib/export/coach-backup.ts": 1070,
|
||||
"lib/export/profile-backup.ts": 1019,
|
||||
"lib/fitbit/client.ts": 1262,
|
||||
@@ -86,6 +86,6 @@
|
||||
"lib/validations/inbound-documents.ts": 1018,
|
||||
"lib/withings/sync-sleep.ts": 884,
|
||||
"lib/withings/sync.ts": 933,
|
||||
"proxy.ts": 865
|
||||
"proxy.ts": 846
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@
|
||||
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 258,
|
||||
"app/api/auth/register/route.ts :: apiHandler(arg)": 266,
|
||||
"app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309,
|
||||
"app/api/cycle/calendar/route.ts :: apiHandler(arg)": 230,
|
||||
"app/api/cycle/day-logs/bulk/route.ts :: postBulk": 213,
|
||||
"app/api/cycle/insights/route.ts :: apiHandler(arg)": 226,
|
||||
"app/api/devices/route.ts :: apiHandler(arg)": 226,
|
||||
@@ -81,7 +80,7 @@
|
||||
"components/cycle/bbt-chart.tsx :: BbtChart": 244,
|
||||
"components/cycle/cycle-calendar.tsx :: CycleCalendar": 292,
|
||||
"components/cycle/cycle-settings.tsx :: CycleSettings": 228,
|
||||
"components/cycle/cycle-view.tsx :: CycleView": 408,
|
||||
"components/cycle/cycle-view.tsx :: CycleView": 407,
|
||||
"components/cycle/log-day-sheet.tsx :: LogDaySheet": 753,
|
||||
"components/daily/today-hero.tsx :: TodayHero": 488,
|
||||
"components/dashboard/dashboard-header.tsx :: DashboardHeader": 215,
|
||||
@@ -242,7 +241,7 @@
|
||||
"hooks/use-sheet-drag.ts :: createSheetDrag": 312,
|
||||
"lib/activity/calendar-math.ts :: buildActivityCalendar": 204,
|
||||
"lib/ai/anthropic-client.ts :: generateCompletion": 294,
|
||||
"lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 218,
|
||||
"lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 202,
|
||||
"lib/ai/coach/glp1-snapshot.ts :: buildGlp1SnapshotBlock": 218,
|
||||
"lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 880,
|
||||
"lib/ai/coach/tools/loop.ts :: runCoachToolLoop": 438,
|
||||
@@ -335,7 +334,7 @@
|
||||
"lib/notifications/senders/web-push.ts :: sendViaWebPush": 268,
|
||||
"lib/personal-records/pr-detection-worker.ts :: detectPersonalRecordsForUser": 239,
|
||||
"lib/personal-records/pr-direction.ts :: getPRDirection": 208,
|
||||
"lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 272,
|
||||
"lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 261,
|
||||
"lib/strava/sync.ts :: runStravaSync": 219,
|
||||
"lib/support/report.ts :: collectSupportReportFacts": 332,
|
||||
"lib/targets/build-response.ts :: buildTargetsResponse": 330,
|
||||
@@ -346,6 +345,6 @@
|
||||
"lib/withings/sync-ecg.ts :: syncUserEcg": 202,
|
||||
"lib/withings/sync-sleep.ts :: syncUserSleep": 414,
|
||||
"lib/withings/sync.ts :: syncUserMeasurements": 337,
|
||||
"proxy.ts :: proxy": 493
|
||||
"proxy.ts :: proxy": 471
|
||||
}
|
||||
}
|
||||
@@ -2892,7 +2892,7 @@
|
||||
"lib/withings/sync.ts": 6,
|
||||
"lib/withings/webhook-handler.ts": 3,
|
||||
"lib/workouts/list-read.ts": 1,
|
||||
"proxy.ts": 29,
|
||||
"proxy.ts": 27,
|
||||
"types/analytics.ts": 8,
|
||||
"types/backups.ts": 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* #1350 — the calendar feed carries dates and a title, and nothing else.
|
||||
*
|
||||
* A calendar provider keeps everything it fetches, beyond the reach of any
|
||||
* revoke. So this seeds every kind of day-log content the record can hold —
|
||||
* a note, custom symptom labels, a basal temperature, an ovulation test, the
|
||||
* mucus and cervix signs, intercourse, contraception, a pregnancy test — as
|
||||
* recognisable sentinels on the very rows the feed reads, as if a select had
|
||||
* been widened by mistake, and asserts that no rendered body contains any of
|
||||
* them, in either title mode, with history and the fertile window on.
|
||||
*
|
||||
* Written, then broken once on purpose (a description that interpolated the
|
||||
* day's note) to prove it fails.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
cycleDayLog: { findMany: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { addDays } from "@/lib/cycle/day-math";
|
||||
import { buildCycleFeed } from "@/lib/cycle/feed/build";
|
||||
import { renderIcs } from "@/lib/cycle/ics";
|
||||
|
||||
import {
|
||||
FEED,
|
||||
SUBJECT,
|
||||
profileRow,
|
||||
regularCycles,
|
||||
} from "@/lib/cycle/feed/__tests__/fixtures";
|
||||
|
||||
const db = prisma as unknown as {
|
||||
cycleProfile: { findUnique: ReturnType<typeof vi.fn> };
|
||||
menstrualCycle: { findMany: ReturnType<typeof vi.fn> };
|
||||
cycleDayLog: { findMany: ReturnType<typeof vi.fn> };
|
||||
measurement: { findMany: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
const SENTINELS = {
|
||||
note: "SENTINEL-NOTE-zebra",
|
||||
noteEncrypted: "SENTINEL-CIPHER-okapi",
|
||||
symptom: "SENTINEL-SYMPTOM-quokka",
|
||||
symptomKey: "sentinel_symptom_key",
|
||||
bbt: "36.987",
|
||||
wrist: "35.4321",
|
||||
opk: "POSITIVE_LH_SURGE",
|
||||
mucus: "EGG_WHITE",
|
||||
cervix: "HIGH",
|
||||
sex: "SENTINEL-SEX-tapir",
|
||||
contraceptive: "SENTINEL-CONTRACEPTIVE-ibis",
|
||||
pregnancyTest: "SENTINEL-PREGNANCY-lynx",
|
||||
userId: "subject",
|
||||
};
|
||||
|
||||
const FIRST = "2025-11-18";
|
||||
const NOW = new Date("2026-11-06T12:00:00.000Z");
|
||||
|
||||
function stuffedDayLogs() {
|
||||
const cycles = regularCycles(FIRST);
|
||||
const out = [];
|
||||
for (const c of cycles) {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
out.push({
|
||||
id: `log-${c.startDate}-${i}`,
|
||||
userId: SENTINELS.userId,
|
||||
date: addDays(c.startDate, i),
|
||||
flow: i < 4 ? "MEDIUM" : "NONE",
|
||||
basalBodyTempC: Number(SENTINELS.bbt),
|
||||
temperatureExcluded: false,
|
||||
ovulationTest: SENTINELS.opk,
|
||||
cervicalMucus: SENTINELS.mucus,
|
||||
cervixPosition: SENTINELS.cervix,
|
||||
cervixFirmness: null,
|
||||
cervixOpening: null,
|
||||
intermenstrualBleeding: true,
|
||||
sexualActivity: SENTINELS.sex,
|
||||
protectedSex: false,
|
||||
pregnancyTest: SENTINELS.pregnancyTest,
|
||||
progesteroneTest: SENTINELS.pregnancyTest,
|
||||
contraceptive: SENTINELS.contraceptive,
|
||||
notes: SENTINELS.note,
|
||||
notesEncrypted: SENTINELS.noteEncrypted,
|
||||
sensitiveEncrypted: SENTINELS.noteEncrypted,
|
||||
symptomLinks: [
|
||||
{
|
||||
symptom: {
|
||||
key: SENTINELS.symptomKey,
|
||||
label: SENTINELS.symptom,
|
||||
},
|
||||
},
|
||||
],
|
||||
_count: { symptomLinks: 1 },
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
db.cycleProfile.findUnique.mockResolvedValue(
|
||||
profileRow("TRYING_TO_CONCEIVE"),
|
||||
);
|
||||
db.menstrualCycle.findMany.mockResolvedValue(regularCycles(FIRST));
|
||||
db.cycleDayLog.findMany.mockResolvedValue(stuffedDayLogs());
|
||||
db.measurement.findMany.mockResolvedValue([
|
||||
{
|
||||
measuredAt: new Date("2026-11-01T03:00:00Z"),
|
||||
value: Number(SENTINELS.wrist),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe("the calendar feed body", () => {
|
||||
it.each(["DISCREET", "DESCRIPTIVE"] as const)(
|
||||
"carries no day-log content in %s mode",
|
||||
async (titleMode) => {
|
||||
const built = await buildCycleFeed(
|
||||
{
|
||||
...FEED,
|
||||
titleMode,
|
||||
historyCycles: 12,
|
||||
horizonCycles: 6,
|
||||
includeFertile: true,
|
||||
},
|
||||
SUBJECT,
|
||||
NOW,
|
||||
);
|
||||
// Non-empty, or the assertion below would hold for nothing.
|
||||
expect(built.calendar.events.length).toBeGreaterThan(10);
|
||||
const body = renderIcs(built.calendar, NOW);
|
||||
for (const [name, value] of Object.entries(SENTINELS)) {
|
||||
expect(body, `${name} leaked into the feed`).not.toContain(value);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("carries only the properties an all-day date event needs", async () => {
|
||||
const built = await buildCycleFeed(
|
||||
{
|
||||
...FEED,
|
||||
titleMode: "DESCRIPTIVE",
|
||||
historyCycles: 6,
|
||||
includeFertile: true,
|
||||
},
|
||||
SUBJECT,
|
||||
NOW,
|
||||
);
|
||||
const body = renderIcs(built.calendar, NOW);
|
||||
const properties = new Set(
|
||||
body
|
||||
.split("\r\n")
|
||||
.filter((line) => line && !line.startsWith(" "))
|
||||
.map((line) => line.split(/[:;]/)[0]),
|
||||
);
|
||||
expect([...properties].sort()).toEqual(
|
||||
[
|
||||
"BEGIN",
|
||||
"CALSCALE",
|
||||
"CLASS",
|
||||
"DESCRIPTION",
|
||||
"DTEND",
|
||||
"DTSTAMP",
|
||||
"DTSTART",
|
||||
"END",
|
||||
"LAST-MODIFIED",
|
||||
"METHOD",
|
||||
"PRODID",
|
||||
"REFRESH-INTERVAL",
|
||||
"SUMMARY",
|
||||
"TRANSP",
|
||||
"UID",
|
||||
"VERSION",
|
||||
"X-PUBLISHED-TTL",
|
||||
"X-WR-CALNAME",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -511,6 +511,14 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["ConfirmButton", "ConfirmDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/cycle/use-cycle-feed.ts",
|
||||
destroys:
|
||||
"the record's calendar-feed link (#1350); calendars subscribed to it stop updating, and turning it on again issues a new link",
|
||||
triggers: ["components/cycle/cycle-calendar-feed-card.tsx"],
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmButton"],
|
||||
},
|
||||
|
||||
// ── Medications ─────────────────────────────────────────────────────────
|
||||
{
|
||||
|
||||
@@ -75,6 +75,10 @@ function isMaskPlaceholder(words: string[]): boolean {
|
||||
* reason. Prune an entry only when the value genuinely diverges in a locale.
|
||||
*/
|
||||
const LEGIT_IDENTICAL = new Set<string>([
|
||||
// #1350 — "Google Calendar" is the product's own name in the Spanish and
|
||||
// Italian UI, and "Cycle (HealthLog)" is the French word for the same thing.
|
||||
"cycle.feed.fetcher.GOOGLE",
|
||||
"cycle.feed.ics.calendarName",
|
||||
// Vaccine catalogue: clinical proper nouns whose accepted written form is the
|
||||
// same across these locales. "Hib" carries its Latin binomial verbatim, and
|
||||
// "Hepatitis A" / "Hepatitis B" read identically in Spanish. The catalogue
|
||||
|
||||
@@ -77,6 +77,8 @@ const UNAUTHENTICATED: Readonly<Record<string, string>> = {
|
||||
"Starts the identity-provider handoff, for the browser and for the native client.",
|
||||
"/api/auth/oidc/status":
|
||||
"Says whether single sign-on is configured, so the sign-in screen can offer it.",
|
||||
"/api/cycle/feed/{token}":
|
||||
"A calendar provider polls it from its own servers with no session; the `hlf_` path token is the credential.",
|
||||
"/api/notifications/vapid":
|
||||
"The instance's public Web Push key. Public by definition.",
|
||||
"/api/auth/claim/preview":
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* #1350 — the cycle calendar feed at `/api/cycle/feed/<hlf_token>.ics`.
|
||||
*
|
||||
* A calendar provider polls it from its own servers with no cookie, so the
|
||||
* proxy must pass the request through untouched, keep it out of indexes and
|
||||
* referrers, and leave its caching to the route (`private, no-cache` plus an
|
||||
* ETag) rather than stamping `no-store` over the validator path. The
|
||||
* management routes beside it stay cookie-only and are not on the list.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
shouldRunWeb: () => true,
|
||||
}));
|
||||
|
||||
import { PUBLIC_PATHS, proxy } from "../proxy";
|
||||
|
||||
// Built at run time so no token-shaped literal sits in the source.
|
||||
const TOKEN = `hlf_${"7d".repeat(24)}`;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("proxy on /api/cycle/feed/<token>", () => {
|
||||
it("lists the feed prefix, trailing slash included, and not the management namespace", () => {
|
||||
expect(PUBLIC_PATHS).toContain("/api/cycle/feed/");
|
||||
expect(
|
||||
PUBLIC_PATHS.some((p) => "/api/cycle/feed-subscription".startsWith(p)),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("passes a cookie-less GET through to the route", () => {
|
||||
const res = proxy(
|
||||
new NextRequest(`http://localhost/api/cycle/feed/${TOKEN}.ics`, {
|
||||
headers: { "user-agent": "Google-Calendar-Importer" },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("x-middleware-next")).toBe("1");
|
||||
expect(res.headers.get("location")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the token out of indexes and referrers", () => {
|
||||
const res = proxy(
|
||||
new NextRequest(`http://localhost/api/cycle/feed/${TOKEN}.ics`),
|
||||
);
|
||||
expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow");
|
||||
expect(res.headers.get("referrer-policy")).toBe("no-referrer");
|
||||
});
|
||||
|
||||
it("leaves the route's Cache-Control alone, so the ETag path survives", () => {
|
||||
const res = proxy(
|
||||
new NextRequest(`http://localhost/api/cycle/feed/${TOKEN}.ics`),
|
||||
);
|
||||
expect(res.headers.get("cache-control")).toBeNull();
|
||||
});
|
||||
|
||||
it("serves a cookie-less GET in demo mode too: a read, not a mutation", () => {
|
||||
vi.stubEnv("DEMO_MODE", "true");
|
||||
const res = proxy(
|
||||
new NextRequest(`http://localhost/api/cycle/feed/${TOKEN}.ics`),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("refuses a demo-mode mint at the edge", () => {
|
||||
vi.stubEnv("DEMO_MODE", "true");
|
||||
const res = proxy(
|
||||
new NextRequest("http://localhost/api/cycle/feed-subscription", {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -97,6 +97,17 @@ const TOKEN_ARM_COOKIE_PROOF_ROUTES = [
|
||||
"app/api/fhir/Patient/$everything/route.ts",
|
||||
].sort();
|
||||
|
||||
/**
|
||||
* Routes that resolve through `requireRecordAuth` 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.
|
||||
*/
|
||||
const RECORD_COOKIE_ONLY_PROOF_ROUTES = [
|
||||
"app/api/cycle/feed-subscription/route.ts",
|
||||
"app/api/cycle/feed-subscription/rotate/route.ts",
|
||||
].sort();
|
||||
|
||||
/**
|
||||
* The connection consent. It resolves the session through `getSession`
|
||||
* (OAuth answers in its own error format, not the envelope) and asks for the
|
||||
@@ -143,11 +154,30 @@ describe("the recent-proof gate", () => {
|
||||
[
|
||||
...COOKIE_PROOF_ROUTES,
|
||||
...TOKEN_ARM_COOKIE_PROOF_ROUTES,
|
||||
...RECORD_COOKIE_ONLY_PROOF_ROUTES,
|
||||
...CONSENT_PROOF_ROUTES,
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
// 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);
|
||||
for (const proof of proofs) {
|
||||
const before = src.slice(0, proof.index);
|
||||
const handlerStart = before.lastIndexOf("apiHandler(");
|
||||
expect(handlerStart, rel).toBeGreaterThan(-1);
|
||||
expect(before.slice(handlerStart), rel).toMatch(
|
||||
/if \(auth\.authMethod !== "cookie"\) return cookieOnlyRefusal\(\);/,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("a route that resolves either transport asks only on the cookie", () => {
|
||||
for (const rel of TOKEN_ARM_COOKIE_PROOF_ROUTES) {
|
||||
expect(read(rel), rel).toMatch(
|
||||
|
||||
@@ -294,6 +294,14 @@ describe("S2 — the URL-token authentication set is frozen", () => {
|
||||
// action-bound, short-lived token in the path is the credential. Rate
|
||||
// limited before verification, single-use through the mood entry's unique
|
||||
// key, and scrubbed from logs by `PATH_SECRET_PATHS`.
|
||||
// #1350 — the cycle calendar feed. A calendar provider polls it from its
|
||||
// own servers with no session, so the `hlf_` path segment is the
|
||||
// credential. Rate limited before the lookup, HMAC-hashed at rest, every
|
||||
// gate (account, operator switch, cycle module, a carrier's MANAGE grant)
|
||||
// re-read per fetch behind one 404, dates-only content, and scrubbed from
|
||||
// logs by `PATH_SECRET_PATHS`.
|
||||
"app/api/cycle/feed/[token]/route.ts":
|
||||
"calendar provider polls without a session; the hlf_ path token is the credential",
|
||||
"app/api/notifications/action/[token]/route.ts":
|
||||
"signed single-use quick-answer token from a notification action",
|
||||
};
|
||||
@@ -329,6 +337,7 @@ describe("S2 — the URL-token authentication set is frozen", () => {
|
||||
expect(bySegment.length).toBeGreaterThan(0);
|
||||
expect(bySegment).toEqual([
|
||||
"app/api/c/[token]/unlock/route.ts",
|
||||
"app/api/cycle/feed/[token]/route.ts",
|
||||
"app/api/notifications/action/[token]/route.ts",
|
||||
"app/api/whoop/webhook/[token]/route.ts",
|
||||
"app/api/withings/webhook/[token]/route.ts",
|
||||
|
||||
@@ -1403,6 +1403,14 @@ 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.",
|
||||
@@ -1879,6 +1887,16 @@ 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"],
|
||||
@@ -2295,8 +2313,12 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* Ending a running course (#1280) joins the record list and the
|
||||
* 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.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 276;
|
||||
const FROZEN_ENTRY_COUNT = 280;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
@@ -3074,7 +3096,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(93);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(97);
|
||||
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(
|
||||
({ handlerModule, action, level }) =>
|
||||
|
||||
@@ -133,6 +133,8 @@ const GATED_ROUTES: Record<string, string> = {
|
||||
"POST /api/account/grants": "web",
|
||||
"POST /api/admin/backups/[id]/restore": "web",
|
||||
"POST /api/admin/backups/upload": "web",
|
||||
"POST /api/cycle/feed-subscription": "web",
|
||||
"POST /api/cycle/feed-subscription/rotate": "web",
|
||||
"POST /api/admin/encryption/rotate": "web",
|
||||
"POST /api/admin/users/[id]/reset-password": "web",
|
||||
"POST /api/auth/me/mfa/disable": "web",
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => {
|
||||
});
|
||||
|
||||
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(93);
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(97);
|
||||
expect(
|
||||
new Set(
|
||||
ADMITTED_MUTATING_HANDLERS.map(
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { CycleCalendarFeedCard } from "../cycle-calendar-feed-card";
|
||||
import type { CycleFeedStatus } from "../use-cycle-feed";
|
||||
|
||||
function render(status: CycleFeedStatus) {
|
||||
const client = new QueryClient();
|
||||
client.setQueryData(queryKeys.cycleFeedSubscription(), status);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<CycleCalendarFeedCard />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const FEED = {
|
||||
tokenPrefix: "hlf_ab12",
|
||||
createdAt: "2026-11-01T10:00:00.000Z",
|
||||
lastFetchedAt: null,
|
||||
fetchCount: 0,
|
||||
lastFetcher: null,
|
||||
horizonCycles: 3,
|
||||
historyCycles: 0,
|
||||
includeFertile: false,
|
||||
titleMode: "DISCREET" as const,
|
||||
};
|
||||
|
||||
describe("<CycleCalendarFeedCard>", () => {
|
||||
it("says what a provider keeps before it offers the button", () => {
|
||||
const html = render({
|
||||
feed: null,
|
||||
projectionState: null,
|
||||
fertileAvailable: false,
|
||||
});
|
||||
const disclosure = html.indexOf("stores these dates on its servers");
|
||||
const button = html.indexOf("Turn on subscription");
|
||||
expect(disclosure).toBeGreaterThan(-1);
|
||||
expect(button).toBeGreaterThan(disclosure);
|
||||
});
|
||||
|
||||
it("shows only the prefix of a live link, and no URL", () => {
|
||||
const html = render({
|
||||
feed: FEED,
|
||||
projectionState: "OK",
|
||||
fertileAvailable: false,
|
||||
});
|
||||
expect(html).toContain("hlf_ab12");
|
||||
expect(html).not.toContain("/api/cycle/feed/");
|
||||
expect(html).toContain("Not fetched yet.");
|
||||
// The fertile option exists only for a conception goal.
|
||||
expect(html).not.toContain("Fertile window");
|
||||
});
|
||||
|
||||
it("explains a shorter feed in the muted tone", () => {
|
||||
const html = render({
|
||||
feed: FEED,
|
||||
projectionState: "LATE",
|
||||
fertileAvailable: true,
|
||||
});
|
||||
expect(html).toContain('data-slot="cycle-feed-state"');
|
||||
expect(html).toContain("later than expected");
|
||||
expect(html).toContain("Fertile window");
|
||||
});
|
||||
});
|
||||
@@ -104,3 +104,59 @@ describe("<PredictionsPanel>", () => {
|
||||
expect(html).toContain("Prediction is off");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<PredictionsPanel> later cycles (#1350)", () => {
|
||||
const projection = [
|
||||
{
|
||||
k: 1,
|
||||
start: "2026-07-01",
|
||||
periodLength: 5,
|
||||
bandStart: "2026-06-29",
|
||||
bandEnd: "2026-07-03",
|
||||
},
|
||||
{
|
||||
k: 2,
|
||||
start: "2026-07-29",
|
||||
periodLength: 5,
|
||||
bandStart: "2026-07-26",
|
||||
bandEnd: "2026-08-01",
|
||||
},
|
||||
{
|
||||
k: 3,
|
||||
start: "2026-08-26",
|
||||
periodLength: 5,
|
||||
bandStart: "2026-08-22",
|
||||
bandEnd: "2026-08-30",
|
||||
},
|
||||
];
|
||||
|
||||
it("lists the cycles after the next one, as the server's windows", () => {
|
||||
const html = render(
|
||||
<PredictionsPanel
|
||||
prediction={basePrediction}
|
||||
rawChartMode={false}
|
||||
history={emptyHistory}
|
||||
projection={projection}
|
||||
projectionState="OK"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="cycle-projection"');
|
||||
expect(html).toContain("Later cycles");
|
||||
// Two later rows; cycle 1 is the next-period row above them.
|
||||
expect(html.match(/Around /g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("states a late period instead of listing later cycles", () => {
|
||||
const html = render(
|
||||
<PredictionsPanel
|
||||
prediction={basePrediction}
|
||||
rawChartMode={false}
|
||||
history={emptyHistory}
|
||||
projection={[]}
|
||||
projectionState="LATE"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="cycle-projection-late"');
|
||||
expect(html).not.toContain('data-slot="cycle-projection"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CalendarSync, Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import { formatRelativeTime } from "@/lib/i18n/relative-time";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import {
|
||||
useCycleFeedStatus,
|
||||
useMintCycleFeed,
|
||||
useRevokeCycleFeed,
|
||||
useUpdateCycleFeed,
|
||||
type CycleFeedMinted,
|
||||
type CycleFeedOptionsPatch,
|
||||
type CycleFeedStatus,
|
||||
type FeedTitleMode,
|
||||
} from "./use-cycle-feed";
|
||||
|
||||
/**
|
||||
* #1350 — the calendar subscription card on the cycle settings tab.
|
||||
*
|
||||
* Off by default, and the disclosure comes before the button: a calendar
|
||||
* provider keeps what it fetched, and turning the link off later reaches none
|
||||
* of it. The link itself is shown once, right after it is made; after that the
|
||||
* card knows only its prefix. Rendered only for the owner and a MANAGE
|
||||
* delegate (the tab's own gate); the routes refuse everyone else regardless.
|
||||
*/
|
||||
|
||||
const HORIZONS = [1, 3, 6] as const;
|
||||
const HISTORIES = [0, 3, 6, 12] as const;
|
||||
const TITLE_MODES: FeedTitleMode[] = ["DISCREET", "DESCRIPTIVE"];
|
||||
|
||||
/** A long URL with its middle elided, so both ends stay recognisable. */
|
||||
function middleTruncate(url: string, keep = 28): string {
|
||||
return url.length <= keep * 2 + 1
|
||||
? url
|
||||
: `${url.slice(0, keep)}…${url.slice(-keep / 2)}`;
|
||||
}
|
||||
|
||||
export function CycleCalendarFeedCard() {
|
||||
const { t } = useTranslations();
|
||||
const status = useCycleFeedStatus();
|
||||
const proof = useRecentProof();
|
||||
const mint = useMintCycleFeed(proof);
|
||||
const [minted, setMinted] = useState<CycleFeedMinted | null>(null);
|
||||
const [mintError, setMintError] = useState<string | null>(null);
|
||||
|
||||
async function handleMint(kind: "create" | "rotate") {
|
||||
setMintError(null);
|
||||
try {
|
||||
setMinted(await mint.mutateAsync(kind));
|
||||
} catch (err) {
|
||||
setMintError(recentProofErrorMessage(err, t("cycle.feed.error")));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="cycle-calendar-feed">
|
||||
<SettingsCardHeader
|
||||
icon={CalendarSync}
|
||||
title={t("cycle.feed.title")}
|
||||
description={t("cycle.feed.description")}
|
||||
/>
|
||||
{status.isError ? (
|
||||
<QueryErrorCard
|
||||
error={status.error}
|
||||
title={t("cycle.loadError")}
|
||||
onRetry={() => void status.refetch()}
|
||||
/>
|
||||
) : status.isLoading || !status.data ? (
|
||||
<div className="flex h-16 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : status.data.feed === null ? (
|
||||
<OffState
|
||||
pending={mint.isPending}
|
||||
error={mintError}
|
||||
onTurnOn={() => void handleMint("create")}
|
||||
/>
|
||||
) : (
|
||||
<OnState
|
||||
status={status.data}
|
||||
minted={minted}
|
||||
pending={mint.isPending}
|
||||
error={mintError}
|
||||
onRotate={() => void handleMint("rotate")}
|
||||
onRevoked={() => setMinted(null)}
|
||||
/>
|
||||
)}
|
||||
{proof.dialog}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function OffState({
|
||||
pending,
|
||||
error,
|
||||
onTurnOn,
|
||||
}: {
|
||||
pending: boolean;
|
||||
error: string | null;
|
||||
onTurnOn: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm">{t("cycle.feed.disclosure")}</p>
|
||||
{error ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
onClick={onTurnOn}
|
||||
disabled={pending}
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("cycle.feed.turnOn")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function OnState({
|
||||
status,
|
||||
minted,
|
||||
pending,
|
||||
error,
|
||||
onRotate,
|
||||
onRevoked,
|
||||
}: {
|
||||
status: CycleFeedStatus;
|
||||
minted: CycleFeedMinted | null;
|
||||
pending: boolean;
|
||||
error: string | null;
|
||||
onRotate: () => void;
|
||||
onRevoked: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const revoke = useRevokeCycleFeed();
|
||||
const feed = status.feed!;
|
||||
const state = status.projectionState;
|
||||
|
||||
return (
|
||||
<>
|
||||
{minted ? (
|
||||
<LinkPanel minted={minted} />
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.feed.linkHidden", { prefix: feed.tokenPrefix })}
|
||||
</p>
|
||||
)}
|
||||
<FetchLine status={status} />
|
||||
{state === "LEARNING" || state === "LATE" || state === "OFF" ? (
|
||||
<p
|
||||
className="text-muted-foreground text-sm"
|
||||
data-slot="cycle-feed-state"
|
||||
>
|
||||
{t(`cycle.projection.state.${state}`)}
|
||||
</p>
|
||||
) : null}
|
||||
<Separator />
|
||||
<FeedOptions status={status} />
|
||||
<HelpList />
|
||||
{error || revoke.isError ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{error ?? t("cycle.feed.error")}
|
||||
</p>
|
||||
) : null}
|
||||
<SettingsCardActions className="flex-col items-stretch sm:flex-row sm:items-center">
|
||||
<ConfirmButton
|
||||
label={t("cycle.feed.turnOff")}
|
||||
title={t("cycle.feed.turnOffTitle")}
|
||||
body={t("cycle.feed.turnOffBody")}
|
||||
confirmLabel={t("cycle.feed.turnOff")}
|
||||
onConfirm={() => revoke.mutate(undefined, { onSuccess: onRevoked })}
|
||||
pending={revoke.isPending}
|
||||
variant="destructive"
|
||||
destructive
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
/>
|
||||
<ConfirmButton
|
||||
label={t("cycle.feed.rotate")}
|
||||
title={t("cycle.feed.rotateTitle")}
|
||||
body={t("cycle.feed.rotateBody")}
|
||||
confirmLabel={t("cycle.feed.rotate")}
|
||||
onConfirm={onRotate}
|
||||
pending={pending}
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
/>
|
||||
</SettingsCardActions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The link, the one time it exists in the client. */
|
||||
function LinkPanel({ minted }: { minted: CycleFeedMinted }) {
|
||||
const { t } = useTranslations();
|
||||
const [copy, setCopy] = useState<"idle" | "copied" | "failed">("idle");
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(minted.url);
|
||||
setCopy("copied");
|
||||
} catch {
|
||||
setCopy("failed");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-slot="cycle-feed-link">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cycle-feed-url">{t("cycle.feed.linkLabel")}</Label>
|
||||
<code
|
||||
id="cycle-feed-url"
|
||||
className="bg-muted block rounded-md px-3 py-2 text-sm break-all"
|
||||
title={minted.url}
|
||||
>
|
||||
{middleTruncate(minted.url)}
|
||||
</code>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("cycle.feed.linkOnce")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
onClick={() => void handleCopy()}
|
||||
>
|
||||
{t("cycle.feed.copy")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
>
|
||||
<a href={minted.webcalUrl}>{t("cycle.feed.openInCalendar")}</a>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm" aria-live="polite">
|
||||
{copy === "copied"
|
||||
? t("cycle.feed.copied")
|
||||
: copy === "failed"
|
||||
? t("cycle.feed.copyFailed")
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FetchLine({ status }: { status: CycleFeedStatus }) {
|
||||
const { t, locale } = useTranslations();
|
||||
const feed = status.feed!;
|
||||
if (feed.lastFetchedAt === null) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.feed.notFetched")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const when = formatRelativeTime(feed.lastFetchedAt, t, locale);
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.feed.lastFetched", {
|
||||
when,
|
||||
fetcher: t(`cycle.feed.fetcher.${feed.lastFetcher ?? "OTHER"}`),
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedOptions({ status }: { status: CycleFeedStatus }) {
|
||||
const { t } = useTranslations();
|
||||
const update = useUpdateCycleFeed();
|
||||
const feed = status.feed!;
|
||||
const save = (patch: CycleFeedOptionsPatch) => update.mutate(patch);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cycle-feed-horizon">{t("cycle.feed.horizon")}</Label>
|
||||
<NativeSelect
|
||||
id="cycle-feed-horizon"
|
||||
value={String(feed.horizonCycles)}
|
||||
disabled={update.isPending}
|
||||
onChange={(e) => save({ horizonCycles: Number(e.target.value) })}
|
||||
>
|
||||
{HORIZONS.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cycle-feed-history">{t("cycle.feed.history")}</Label>
|
||||
<NativeSelect
|
||||
id="cycle-feed-history"
|
||||
value={String(feed.historyCycles)}
|
||||
disabled={update.isPending}
|
||||
onChange={(e) => save({ historyCycles: Number(e.target.value) })}
|
||||
>
|
||||
{HISTORIES.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n === 0 ? t("cycle.feed.historyNone") : n}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cycle-feed-title">{t("cycle.feed.titles")}</Label>
|
||||
<NativeSelect
|
||||
id="cycle-feed-title"
|
||||
value={feed.titleMode}
|
||||
disabled={update.isPending}
|
||||
onChange={(e) =>
|
||||
save({ titleMode: e.target.value as FeedTitleMode })
|
||||
}
|
||||
>
|
||||
{TITLE_MODES.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{t(`cycle.feed.titleMode.${m}`)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</div>
|
||||
{status.fertileAvailable ? (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="cycle-feed-fertile" className="text-sm">
|
||||
{t("cycle.feed.fertile")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.feed.fertileDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="cycle-feed-fertile"
|
||||
checked={feed.includeFertile}
|
||||
disabled={update.isPending}
|
||||
onCheckedChange={(v) => save({ includeFertile: v })}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{update.isError ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{t("cycle.feed.error")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpList() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm font-medium">{t("cycle.feed.help.title")}</p>
|
||||
<ul className="text-muted-foreground list-disc space-y-1 pl-5 text-sm">
|
||||
<li>{t("cycle.feed.help.google")}</li>
|
||||
<li>{t("cycle.feed.help.apple")}</li>
|
||||
<li>{t("cycle.feed.help.outlook")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { CycleCalendarFeedCard } from "./cycle-calendar-feed-card";
|
||||
import { CycleSettings } from "./cycle-settings";
|
||||
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.
|
||||
*/
|
||||
export function CycleSettingsTab({ profile }: { profile: CycleProfileDTO }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CycleSettings key={profile.updatedAt} profile={profile} />
|
||||
<CycleCalendarFeedCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import { PhaseEducationCard } from "./phase-education-card";
|
||||
import { PredictionsPanel } from "./predictions-panel";
|
||||
import { CyclePhaseHeadline, CyclePhaseCrosstab } from "./cycle-phase-crosstab";
|
||||
import { CycleSymptomPatterns } from "./cycle-symptom-patterns";
|
||||
import { CycleSettings } from "./cycle-settings";
|
||||
import { CycleSettingsTab } from "./cycle-settings-tab";
|
||||
import { PHASE_HUE } from "./phase-tokens";
|
||||
import { monthOf, monthWindowOutside } from "./calendar-window";
|
||||
import {
|
||||
@@ -417,6 +417,8 @@ export function CycleView() {
|
||||
prediction={calendar.data?.prediction ?? null}
|
||||
rawChartMode={calendar.data?.profile.rawChartMode ?? false}
|
||||
history={history.data?.data}
|
||||
projection={calendar.data?.projection}
|
||||
projectionState={calendar.data?.projectionState ?? null}
|
||||
/>
|
||||
<CycleHistoryChart
|
||||
history={history.data?.data}
|
||||
@@ -471,10 +473,7 @@ export function CycleView() {
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : profileQuery.data && canManageCycle ? (
|
||||
<CycleSettings
|
||||
key={profileQuery.data.updatedAt}
|
||||
profile={profileQuery.data}
|
||||
/>
|
||||
<CycleSettingsTab profile={profileQuery.data} />
|
||||
) : null}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -14,7 +14,13 @@ import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import type { Formatters } from "@/lib/format-locale";
|
||||
import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens";
|
||||
import type { CyclePrediction, CycleHistoryResponse } from "./types";
|
||||
import type {
|
||||
CyclePrediction,
|
||||
CycleHistoryResponse,
|
||||
ProjectedCycle,
|
||||
ProjectionState,
|
||||
} from "./types";
|
||||
import { UpcomingCycles } from "./upcoming-cycles";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
|
||||
/**
|
||||
@@ -59,12 +65,17 @@ export interface PredictionsPanelProps {
|
||||
prediction: CyclePrediction | null;
|
||||
rawChartMode: boolean;
|
||||
history: CycleHistoryResponse | undefined;
|
||||
/** #1350 — the server's projection of the cycles after the next one. */
|
||||
projection?: readonly ProjectedCycle[];
|
||||
projectionState?: ProjectionState | null;
|
||||
}
|
||||
|
||||
export function PredictionsPanel({
|
||||
prediction,
|
||||
rawChartMode,
|
||||
history,
|
||||
projection = [],
|
||||
projectionState = null,
|
||||
}: PredictionsPanelProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -94,6 +105,7 @@ export function PredictionsPanel({
|
||||
body={t("cycle.predictions.stillLearningBody")}
|
||||
/>
|
||||
<NextPeriod prediction={prediction} />
|
||||
<UpcomingCycles projection={projection} state={projectionState} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -119,6 +131,7 @@ export function PredictionsPanel({
|
||||
value={formatDate(prediction.predictedOvulation, fmt)}
|
||||
/>
|
||||
) : null}
|
||||
<UpcomingCycles projection={projection} state={projectionState} />
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -114,10 +114,28 @@ export interface CalendarResponse {
|
||||
* phase band — render the calm "learning your cycle" state over the grid.
|
||||
*/
|
||||
stillLearning: boolean;
|
||||
/**
|
||||
* #1350 — the next cycles with widening bands, resolved server-side (the
|
||||
* calendar feed serves the same list). Render as given; never extrapolate.
|
||||
*/
|
||||
projection: ProjectedCycle[];
|
||||
projectionState: ProjectionState;
|
||||
days: CalendarDay[];
|
||||
meta: { generatedAt: string };
|
||||
}
|
||||
|
||||
/** Mirrors `ProjectionState` in `src/lib/cycle/projection.ts`. */
|
||||
export type ProjectionState = "OK" | "LEARNING" | "LATE" | "OFF";
|
||||
|
||||
/** Mirrors `ProjectedCycle` in `src/lib/cycle/projection.ts`. */
|
||||
export interface ProjectedCycle {
|
||||
k: number;
|
||||
start: string;
|
||||
periodLength: number;
|
||||
bandStart: string;
|
||||
bandEnd: string;
|
||||
}
|
||||
|
||||
export interface MenstrualCycleDTO {
|
||||
id: string;
|
||||
startDate: string;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { FLOW_HUE } from "./phase-tokens";
|
||||
import type { ProjectedCycle, ProjectionState } from "./types";
|
||||
|
||||
/**
|
||||
* #1350 — the periods after the next one, each as the window its start may
|
||||
* fall in. The list and its bands are the server's (`/api/cycle/calendar`
|
||||
* `projection`, the same list the calendar feed serves); this only renders
|
||||
* them. The band widens from one row to the next, and the server stops the
|
||||
* list where a date would be a guess.
|
||||
*
|
||||
* The next period itself is the row above this one in the predictions card,
|
||||
* so cycle 1 is skipped here. A late period is stated, in the muted tone: a
|
||||
* late period is a fact about the calendar, not a warning.
|
||||
*/
|
||||
export function UpcomingCycles({
|
||||
projection,
|
||||
state,
|
||||
}: {
|
||||
projection: readonly ProjectedCycle[];
|
||||
state: ProjectionState | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
if (state === "LATE") {
|
||||
return (
|
||||
<p
|
||||
className="text-muted-foreground text-sm"
|
||||
data-slot="cycle-projection-late"
|
||||
>
|
||||
{t("cycle.projection.state.LATE")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const later = projection.filter((c) => c.k >= 2);
|
||||
if (later.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-slot="cycle-projection">
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
{t("cycle.projection.title")}
|
||||
</p>
|
||||
<ul className="divide-border divide-y">
|
||||
{later.map((c) => (
|
||||
<li key={c.k} className="flex items-center gap-2 py-2 text-sm">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full opacity-60"
|
||||
style={{ backgroundColor: FLOW_HUE }}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cycle.projection.around", {
|
||||
date: fmt.dateShortSmartCalendar(c.start),
|
||||
})}
|
||||
</span>
|
||||
<span className="text-foreground ml-auto font-medium tabular-nums">
|
||||
{t("cycle.predictions.nextPeriodWindow", {
|
||||
low: fmt.dateShortSmartCalendar(c.bandStart),
|
||||
high: fmt.dateShortSmartCalendar(c.bandEnd),
|
||||
})}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* #1350 — the calendar subscription's read and write hooks.
|
||||
*
|
||||
* Every value the card shows is the server's: the status read carries the
|
||||
* options, the fetch statistics as a calendar-app family, and the projection
|
||||
* state that explains a shorter feed. The URL exists in the client only in
|
||||
* the one mint or rotate response that returns it, held in the card's state
|
||||
* and gone when the card unmounts.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import type { ProjectionState } from "./types";
|
||||
|
||||
export type FeedTitleMode = "DISCREET" | "DESCRIPTIVE";
|
||||
export type FeedFetcher = "GOOGLE" | "APPLE" | "MICROSOFT" | "OTHER";
|
||||
|
||||
export interface CycleFeedSummary {
|
||||
tokenPrefix: string;
|
||||
createdAt: string;
|
||||
lastFetchedAt: string | null;
|
||||
fetchCount: number;
|
||||
lastFetcher: FeedFetcher | null;
|
||||
horizonCycles: number;
|
||||
historyCycles: number;
|
||||
includeFertile: boolean;
|
||||
titleMode: FeedTitleMode;
|
||||
}
|
||||
|
||||
export interface CycleFeedStatus {
|
||||
feed: CycleFeedSummary | null;
|
||||
projectionState: ProjectionState | null;
|
||||
fertileAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface CycleFeedMinted {
|
||||
url: string;
|
||||
webcalUrl: string;
|
||||
feed: CycleFeedSummary;
|
||||
}
|
||||
|
||||
export type CycleFeedOptionsPatch = Partial<
|
||||
Pick<
|
||||
CycleFeedSummary,
|
||||
"horizonCycles" | "historyCycles" | "includeFertile" | "titleMode"
|
||||
>
|
||||
>;
|
||||
|
||||
const PATH = "/api/cycle/feed-subscription";
|
||||
|
||||
export function useCycleFeedStatus(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cycleFeedSubscription(),
|
||||
queryFn: () => apiGet<CycleFeedStatus>(PATH),
|
||||
staleTime: 60_000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
function useInvalidateStatus() {
|
||||
const qc = useQueryClient();
|
||||
return () =>
|
||||
qc.invalidateQueries({ queryKey: queryKeys.cycleFeedSubscription() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the subscription on, or rotate its link. `proof` is the recent-proof
|
||||
* flow: both answer 401 `auth.reproof.required` without a fresh proof, and
|
||||
* `proof.run` opens the re-proof dialog and retries.
|
||||
*/
|
||||
export function useMintCycleFeed(proof: {
|
||||
run: <T>(action: () => Promise<T>) => Promise<T>;
|
||||
}) {
|
||||
const invalidate = useInvalidateStatus();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.cycleFeedSubscription(),
|
||||
mutationFn: (kind: "create" | "rotate") =>
|
||||
kind === "create"
|
||||
? proof.run(() =>
|
||||
apiPost<CycleFeedMinted>("/api/cycle/feed-subscription"),
|
||||
)
|
||||
: proof.run(() =>
|
||||
apiPost<CycleFeedMinted>("/api/cycle/feed-subscription/rotate"),
|
||||
),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCycleFeed() {
|
||||
const invalidate = useInvalidateStatus();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.cycleFeedSubscription(),
|
||||
mutationFn: (patch: CycleFeedOptionsPatch) =>
|
||||
apiPatch<CycleFeedStatus>(PATH, patch),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeCycleFeed() {
|
||||
const invalidate = useInvalidateStatus();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.cycleFeedSubscription(),
|
||||
mutationFn: () => apiDelete<{ revoked: true }>(PATH),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
@@ -101,6 +101,16 @@ describe("GlitchTip forwarder — path-segment secret redaction", () => {
|
||||
expect(input.url).not.toContain(shareToken);
|
||||
});
|
||||
|
||||
it("does not forward a cycle calendar-feed token verbatim", async () => {
|
||||
// Built at run time so no token-shaped literal sits in the source.
|
||||
const feedToken = `hlf_${"3c".repeat(24)}`;
|
||||
const input = await forwardedPayload(
|
||||
`http://localhost/api/cycle/feed/${feedToken}.ics`,
|
||||
);
|
||||
expect(input.url).not.toContain(feedToken);
|
||||
expect(input.url).toContain("/api/cycle/feed/");
|
||||
});
|
||||
|
||||
it("still strips the query string", async () => {
|
||||
const input = await forwardedPayload(
|
||||
"http://localhost/api/auth/oidc/callback?code=abc123&state=xyz789",
|
||||
|
||||
@@ -14,6 +14,10 @@ describe("looksSecretShaped", () => {
|
||||
["an MCP authorization code", "hlac_eyJ0eXAi"],
|
||||
["an MCP refresh token", "hlrt_eyJ0eXAi"],
|
||||
["an MCP client id", "hlc_eyJ0eXAi"],
|
||||
[
|
||||
"a cycle calendar-feed token",
|
||||
`url /api/cycle/feed/hlf_${"9e".repeat(24)}.ics`,
|
||||
],
|
||||
["an OpenAI key", "echoed sk-1234567890"],
|
||||
["an Anthropic key", "sk-ant-api03-xyz12345"],
|
||||
])("matches %s", (_label, text) => {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* #1350 — the RFC 5545 writer behind the calendar feed.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
escapeText,
|
||||
eventUid,
|
||||
foldLine,
|
||||
icsContentHash,
|
||||
icsStamp,
|
||||
renderIcs,
|
||||
type IcsCalendar,
|
||||
} from "../ics";
|
||||
|
||||
const CAL: IcsCalendar = {
|
||||
name: "HealthLog",
|
||||
events: [
|
||||
{
|
||||
uid: "abc@healthlog.invalid",
|
||||
start: "2026-11-10",
|
||||
endExclusive: "2026-11-17",
|
||||
summary: "●",
|
||||
description: "±2 days",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const STAMP = new Date("2026-11-06T09:30:00.000Z");
|
||||
|
||||
describe("renderIcs", () => {
|
||||
it("matches the golden body", () => {
|
||||
expect(renderIcs(CAL, STAMP)).toBe(
|
||||
[
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//HealthLog//Calendar feed//EN",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"METHOD:PUBLISH",
|
||||
"X-WR-CALNAME:HealthLog",
|
||||
"REFRESH-INTERVAL;VALUE=DURATION:PT6H",
|
||||
"X-PUBLISHED-TTL:PT6H",
|
||||
"BEGIN:VEVENT",
|
||||
"UID:abc@healthlog.invalid",
|
||||
"DTSTAMP:20261106T093000Z",
|
||||
"LAST-MODIFIED:20261106T093000Z",
|
||||
"DTSTART;VALUE=DATE:20261110",
|
||||
"DTEND;VALUE=DATE:20261117",
|
||||
"SUMMARY:●",
|
||||
"DESCRIPTION:±2 days",
|
||||
"TRANSP:TRANSPARENT",
|
||||
"CLASS:PRIVATE",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
"",
|
||||
].join("\r\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("ends every line with CRLF and carries no bare LF", () => {
|
||||
const body = renderIcs(CAL, STAMP);
|
||||
expect(body.endsWith("\r\n")).toBe(true);
|
||||
expect(body.replace(/\r\n/g, "")).not.toMatch(/[\r\n]/);
|
||||
});
|
||||
|
||||
it("writes all-day events with an exclusive DTEND and no time zone", () => {
|
||||
const body = renderIcs(CAL, STAMP);
|
||||
expect(body).toContain("DTSTART;VALUE=DATE:20261110");
|
||||
expect(body).toContain("DTEND;VALUE=DATE:20261117");
|
||||
expect(body).not.toContain("VTIMEZONE");
|
||||
expect(body).not.toContain("TZID");
|
||||
});
|
||||
|
||||
it("refuses a malformed day rather than writing a broken calendar", () => {
|
||||
expect(() =>
|
||||
renderIcs(
|
||||
{ name: "x", events: [{ ...CAL.events[0], start: "2026-11-1" }] },
|
||||
STAMP,
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapeText", () => {
|
||||
it("escapes backslash, semicolon, comma and newline", () => {
|
||||
expect(escapeText("a\\b;c,d\ne")).toBe("a\\\\b\\;c\\,d\\ne");
|
||||
expect(escapeText("x\r\ny")).toBe("x\\ny");
|
||||
});
|
||||
});
|
||||
|
||||
describe("foldLine", () => {
|
||||
const octets = (s: string) => Buffer.byteLength(s, "utf8");
|
||||
|
||||
it("leaves a short line alone", () => {
|
||||
expect(foldLine("SUMMARY:short")).toBe("SUMMARY:short");
|
||||
});
|
||||
|
||||
it("folds at 75 octets with a leading space on each continuation", () => {
|
||||
const folded = foldLine(`DESCRIPTION:${"a".repeat(200)}`);
|
||||
const lines = folded.split("\r\n");
|
||||
expect(lines.length).toBeGreaterThan(2);
|
||||
for (const line of lines) expect(octets(line)).toBeLessThanOrEqual(75);
|
||||
for (const line of lines.slice(1)) expect(line.startsWith(" ")).toBe(true);
|
||||
expect(lines.map((l, i) => (i === 0 ? l : l.slice(1))).join("")).toBe(
|
||||
`DESCRIPTION:${"a".repeat(200)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("never splits a multibyte character", () => {
|
||||
// Cyrillic and Hangul are two and three octets each; a byte-counted cut
|
||||
// would land inside one of them sooner or later.
|
||||
const value = `SUMMARY:${"Месячные 생리 ".repeat(12)}`;
|
||||
const lines = foldLine(value).split("\r\n");
|
||||
for (const line of lines) {
|
||||
expect(octets(line)).toBeLessThanOrEqual(75);
|
||||
// A cut through a sequence would leave a replacement character after a
|
||||
// round trip through bytes.
|
||||
expect(Buffer.from(line, "utf8").toString("utf8")).toBe(line);
|
||||
expect(line).not.toContain("�");
|
||||
}
|
||||
expect(lines.map((l, i) => (i === 0 ? l : l.slice(1))).join("")).toBe(
|
||||
value,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("eventUid", () => {
|
||||
it("is stable for a feed and a slot, and unrelated across feeds", () => {
|
||||
const a = eventUid("feed-1", "pred:1");
|
||||
expect(eventUid("feed-1", "pred:1")).toBe(a);
|
||||
expect(eventUid("feed-1", "pred:2")).not.toBe(a);
|
||||
expect(eventUid("feed-2", "pred:1")).not.toBe(a);
|
||||
expect(a).toMatch(/^[a-z2-7]{32}@healthlog\.invalid$/);
|
||||
});
|
||||
|
||||
it("carries neither the feed id nor the slot", () => {
|
||||
const uid = eventUid("cmfeedid123", "obs:cmcycleid456");
|
||||
expect(uid).not.toContain("cmfeedid123");
|
||||
expect(uid).not.toContain("cmcycleid456");
|
||||
});
|
||||
});
|
||||
|
||||
describe("icsContentHash", () => {
|
||||
it("does not depend on the stamp, only on the content", () => {
|
||||
expect(icsContentHash(CAL)).toBe(icsContentHash({ ...CAL }));
|
||||
expect(icsContentHash(CAL)).not.toBe(
|
||||
icsContentHash({ ...CAL, events: [] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("formats a stamp as basic UTC", () => {
|
||||
expect(icsStamp(STAMP)).toBe("20261106T093000Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* #1350 — building a feed's body from the record's rows: the record's own
|
||||
* day, zone and language; a hash that moves only with the content; and the
|
||||
* goal and cold-start gates on the fertile window.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
cycleDayLog: { findMany: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
const localeSpy = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: localeSpy,
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { addDays } from "@/lib/cycle/day-math";
|
||||
import { renderIcs } from "@/lib/cycle/ics";
|
||||
|
||||
import { buildCycleFeed, feedLocale } from "../build";
|
||||
import { DISCREET_SUMMARY } from "../events";
|
||||
import { FEED, SUBJECT, profileRow, regularCycles } from "./fixtures";
|
||||
|
||||
const db = prisma as unknown as {
|
||||
cycleProfile: { findUnique: ReturnType<typeof vi.fn> };
|
||||
menstrualCycle: { findMany: ReturnType<typeof vi.fn> };
|
||||
cycleDayLog: { findMany: ReturnType<typeof vi.fn> };
|
||||
measurement: { findMany: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
// The last start lands on 2026-10-20, so the next period is due mid-November.
|
||||
const FIRST = "2025-11-18";
|
||||
const NOW = new Date("2026-11-06T12:00:00.000Z");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
db.cycleProfile.findUnique.mockResolvedValue(profileRow());
|
||||
db.menstrualCycle.findMany.mockResolvedValue(regularCycles(FIRST));
|
||||
db.cycleDayLog.findMany.mockResolvedValue([]);
|
||||
db.measurement.findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("buildCycleFeed", () => {
|
||||
it("serves the projected periods with discreet titles by default", async () => {
|
||||
const built = await buildCycleFeed(FEED, SUBJECT, NOW);
|
||||
expect(built.projectionState).toBe("OK");
|
||||
expect(built.calendar.name).toBe("HealthLog");
|
||||
expect(built.calendar.events.length).toBe(3);
|
||||
for (const e of built.calendar.events) {
|
||||
expect(e.summary).toBe(DISCREET_SUMMARY);
|
||||
}
|
||||
});
|
||||
|
||||
it("hashes the same inputs on the same day to the same value", async () => {
|
||||
const a = await buildCycleFeed(FEED, SUBJECT, NOW);
|
||||
const b = await buildCycleFeed(
|
||||
FEED,
|
||||
SUBJECT,
|
||||
new Date("2026-11-06T18:00:00.000Z"),
|
||||
);
|
||||
expect(b.contentHash).toBe(a.contentHash);
|
||||
// The events keep their UIDs across rebuilds.
|
||||
expect(b.calendar.events.map((e) => e.uid)).toEqual(
|
||||
a.calendar.events.map((e) => e.uid),
|
||||
);
|
||||
});
|
||||
|
||||
it("moves the hash when a cycle is deleted", async () => {
|
||||
const before = await buildCycleFeed(FEED, SUBJECT, NOW);
|
||||
db.menstrualCycle.findMany.mockResolvedValue(
|
||||
regularCycles(FIRST).slice(0, -1),
|
||||
);
|
||||
const after = await buildCycleFeed(FEED, SUBJECT, NOW);
|
||||
expect(after.contentHash).not.toBe(before.contentHash);
|
||||
});
|
||||
|
||||
it("moves the hash when a day roll takes the forecast past its band", async () => {
|
||||
const before = await buildCycleFeed(FEED, SUBJECT, NOW);
|
||||
const late = await buildCycleFeed(
|
||||
FEED,
|
||||
SUBJECT,
|
||||
new Date("2026-12-15T12:00:00.000Z"),
|
||||
);
|
||||
expect(late.projectionState).toBe("LATE");
|
||||
expect(late.calendar.events).toEqual([]);
|
||||
expect(late.contentHash).not.toBe(before.contentHash);
|
||||
});
|
||||
|
||||
it("reads today in the record's zone, not in UTC", async () => {
|
||||
// Find the next period's band end, then ask at 23:30 UTC on that day: in
|
||||
// Berlin it is already the following day, which is past the band.
|
||||
const onTime = await buildCycleFeed(FEED, SUBJECT, NOW);
|
||||
const first = onTime.calendar.events[0];
|
||||
// DTEND is the band end plus the period length; recover the band end.
|
||||
const bandEnd = addDays(first.endExclusive, -5);
|
||||
const lateEvening = new Date(`${bandEnd}T23:30:00.000Z`);
|
||||
|
||||
const utc = await buildCycleFeed(
|
||||
FEED,
|
||||
{ ...SUBJECT, timezone: "UTC" },
|
||||
lateEvening,
|
||||
);
|
||||
const berlin = await buildCycleFeed(FEED, SUBJECT, lateEvening);
|
||||
expect(utc.projectionState).toBe("OK");
|
||||
expect(berlin.projectionState).toBe("LATE");
|
||||
});
|
||||
|
||||
it("names nothing when the prediction is switched off, and history still works", async () => {
|
||||
db.cycleProfile.findUnique.mockResolvedValue(
|
||||
profileRow("GENERAL_HEALTH", { predictionEnabled: false }),
|
||||
);
|
||||
const off = await buildCycleFeed(
|
||||
{ ...FEED, historyCycles: 3 },
|
||||
SUBJECT,
|
||||
NOW,
|
||||
);
|
||||
expect(off.projectionState).toBe("OFF");
|
||||
expect(off.calendar.events).toHaveLength(3);
|
||||
const starts = off.calendar.events.map((e) => e.start);
|
||||
expect(starts.at(-1)).toBe("2026-10-20");
|
||||
});
|
||||
|
||||
it("treats raw-chart mode as no forecast", async () => {
|
||||
db.cycleProfile.findUnique.mockResolvedValue(
|
||||
profileRow("GENERAL_HEALTH", { rawChartMode: true }),
|
||||
);
|
||||
const built = await buildCycleFeed(FEED, SUBJECT, NOW);
|
||||
expect(built.projectionState).toBe("OFF");
|
||||
expect(built.calendar.events).toEqual([]);
|
||||
});
|
||||
|
||||
describe("the fertile window", () => {
|
||||
it("is added for TRYING_TO_CONCEIVE when asked for", async () => {
|
||||
db.cycleProfile.findUnique.mockResolvedValue(
|
||||
profileRow("TRYING_TO_CONCEIVE"),
|
||||
);
|
||||
const built = await buildCycleFeed(
|
||||
{ ...FEED, includeFertile: true, titleMode: "DESCRIPTIVE" },
|
||||
SUBJECT,
|
||||
NOW,
|
||||
);
|
||||
expect(
|
||||
built.calendar.events.filter(
|
||||
(e) => e.summary === "Fertile window (estimate)",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("is never added for AVOID_PREGNANCY, whatever the option says", async () => {
|
||||
db.cycleProfile.findUnique.mockResolvedValue(
|
||||
profileRow("AVOID_PREGNANCY"),
|
||||
);
|
||||
const built = await buildCycleFeed(
|
||||
{ ...FEED, includeFertile: true, titleMode: "DESCRIPTIVE" },
|
||||
SUBJECT,
|
||||
NOW,
|
||||
);
|
||||
expect(built.calendar.events.map((e) => e.summary)).not.toContain(
|
||||
"Fertile window (estimate)",
|
||||
);
|
||||
expect(built.calendar.events).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("is withheld while the engine is still learning", async () => {
|
||||
db.cycleProfile.findUnique.mockResolvedValue(
|
||||
profileRow("TRYING_TO_CONCEIVE"),
|
||||
);
|
||||
db.menstrualCycle.findMany.mockResolvedValue(
|
||||
regularCycles("2026-09-22", 3),
|
||||
);
|
||||
const built = await buildCycleFeed(
|
||||
{ ...FEED, includeFertile: true, titleMode: "DESCRIPTIVE" },
|
||||
SUBJECT,
|
||||
NOW,
|
||||
);
|
||||
expect(built.projectionState).toBe("LEARNING");
|
||||
expect(built.calendar.events.map((e) => e.summary)).toEqual([
|
||||
"Period (estimate)",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the record's stored language for descriptive titles", async () => {
|
||||
const built = await buildCycleFeed(
|
||||
{ ...FEED, titleMode: "DESCRIPTIVE" },
|
||||
{ ...SUBJECT, locale: "de" },
|
||||
NOW,
|
||||
);
|
||||
expect(built.calendar.name).toBe("Zyklus (HealthLog)");
|
||||
expect(built.calendar.events[0].summary).toBe("Periode (Schätzung)");
|
||||
});
|
||||
|
||||
it("falls back to the instance default for a null locale and never asks the request", async () => {
|
||||
expect(feedLocale(null)).toBe("en");
|
||||
expect(feedLocale("xx")).toBe("en");
|
||||
const built = await buildCycleFeed(
|
||||
{ ...FEED, titleMode: "DESCRIPTIVE" },
|
||||
{ ...SUBJECT, locale: null },
|
||||
NOW,
|
||||
);
|
||||
expect(built.calendar.events[0].summary).toBe("Period (estimate)");
|
||||
// A provider's Accept-Language must never reach the choice.
|
||||
expect(localeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the descriptive description with the likely start and the band", async () => {
|
||||
const built = await buildCycleFeed(
|
||||
{ ...FEED, titleMode: "DESCRIPTIVE" },
|
||||
SUBJECT,
|
||||
NOW,
|
||||
);
|
||||
const body = renderIcs(built.calendar, NOW);
|
||||
expect(body).toMatch(
|
||||
/DESCRIPTION:Most likely start [^\\\r]+\\, ±\d+ days?\r\n/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Shared rows for the calendar-feed tests: a regular record with a year of
|
||||
* 28-day cycles, mocked through the same Prisma calls the loader makes.
|
||||
*/
|
||||
import { addDays } from "@/lib/cycle/day-math";
|
||||
|
||||
export function regularCycles(first: string, count = 13, length = 28) {
|
||||
const out = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const startDate = addDays(first, i * length);
|
||||
out.push({
|
||||
id: `cycle-${i}`,
|
||||
userId: "subject",
|
||||
startDate,
|
||||
endDate: i < count - 1 ? addDays(startDate, length) : null,
|
||||
periodEndDate: i % 2 === 0 ? addDays(startDate, 4) : null,
|
||||
ovulationDate: null,
|
||||
ovulationConfirmed: false,
|
||||
deletedAt: null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function profileRow(goal = "GENERAL_HEALTH", overrides = {}) {
|
||||
return {
|
||||
id: "profile",
|
||||
userId: "subject",
|
||||
goal,
|
||||
cycleTrackingEnabled: true,
|
||||
typicalCycleLength: null,
|
||||
typicalPeriodLength: null,
|
||||
lutealPhaseLength: null,
|
||||
secondarySymptom: "MUCUS",
|
||||
predictionEnabled: true,
|
||||
rawChartMode: false,
|
||||
discreetNotifications: false,
|
||||
sensitiveCategoryEncryption: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export const FEED = {
|
||||
id: "feed-1",
|
||||
horizonCycles: 3,
|
||||
historyCycles: 0,
|
||||
includeFertile: false,
|
||||
titleMode: "DISCREET" as const,
|
||||
};
|
||||
|
||||
export const SUBJECT = {
|
||||
id: "subject",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "en",
|
||||
};
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* #1350 — the anonymous feed fetch: the limit before any lookup, one 404 for
|
||||
* every miss, every gate re-read per fetch, and the ETag / 304 path.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
cycleCalendarFeed: { findFirst: vi.fn(), update: vi.fn() },
|
||||
user: { findFirst: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({ prisma: db }));
|
||||
|
||||
const limits = vi.hoisted(() => ({
|
||||
surface: vi.fn(),
|
||||
token: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: limits.surface,
|
||||
checkRateLimit: limits.token,
|
||||
rateLimitHeaders: () => ({ "Retry-After": "60" }),
|
||||
}));
|
||||
|
||||
const gates = vi.hoisted(() => ({
|
||||
api: vi.fn(),
|
||||
cycle: vi.fn(),
|
||||
grant: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/app-settings", () => ({ isApiGloballyEnabled: gates.api }));
|
||||
vi.mock("@/lib/cycle/gate", () => ({ isCycleAvailableForUser: gates.cycle }));
|
||||
vi.mock("@/lib/sharing/grant-access", async (orig) => ({
|
||||
...(await orig<typeof import("@/lib/sharing/grant-access")>()),
|
||||
findActiveGrant: gates.grant,
|
||||
}));
|
||||
|
||||
const built = vi.hoisted(() => ({ build: vi.fn() }));
|
||||
vi.mock("../build", () => ({ buildCycleFeed: built.build }));
|
||||
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { settleBackgroundTasks } from "@/lib/logging/background-tasks";
|
||||
|
||||
import { serveCycleFeed } from "../serve";
|
||||
|
||||
const TOKEN = `hlf_${"ab".repeat(24)}`;
|
||||
const ALLOWED = { allowed: true, limit: 1, remaining: 1, resetAt: new Date() };
|
||||
|
||||
const FEED_ROW = {
|
||||
id: "feed-1",
|
||||
userId: "subject",
|
||||
createdById: "subject",
|
||||
bodyHash: "old-hash",
|
||||
contentChangedAt: new Date("2026-11-01T00:00:00Z"),
|
||||
horizonCycles: 3,
|
||||
historyCycles: 0,
|
||||
includeFertile: false,
|
||||
titleMode: "DISCREET",
|
||||
};
|
||||
|
||||
function request(path = `${TOKEN}.ics`, headers: Record<string, string> = {}) {
|
||||
return {
|
||||
req: new NextRequest(`http://localhost/api/cycle/feed/${path}`, {
|
||||
headers,
|
||||
}),
|
||||
segment: path,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchFeed(path?: string, headers?: Record<string, string>) {
|
||||
const { req, segment } = request(path, headers);
|
||||
const res = await serveCycleFeed(req, segment);
|
||||
await settleBackgroundTasks();
|
||||
return res;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv("API_TOKEN_HMAC_KEY", "k".repeat(40));
|
||||
limits.surface.mockResolvedValue({ ...ALLOWED, ip: "203.0.113.7" });
|
||||
limits.token.mockResolvedValue(ALLOWED);
|
||||
db.cycleCalendarFeed.findFirst.mockResolvedValue(FEED_ROW);
|
||||
db.cycleCalendarFeed.update.mockResolvedValue({});
|
||||
db.user.findFirst.mockResolvedValue({
|
||||
id: "subject",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "en",
|
||||
});
|
||||
gates.api.mockResolvedValue(true);
|
||||
gates.cycle.mockResolvedValue(true);
|
||||
gates.grant.mockResolvedValue(null);
|
||||
built.build.mockResolvedValue({
|
||||
calendar: {
|
||||
name: "HealthLog",
|
||||
events: [
|
||||
{
|
||||
uid: "u@healthlog.invalid",
|
||||
start: "2026-11-15",
|
||||
endExclusive: "2026-11-22",
|
||||
summary: "●",
|
||||
},
|
||||
],
|
||||
},
|
||||
contentHash: "new-hash",
|
||||
projectionState: "OK",
|
||||
});
|
||||
});
|
||||
|
||||
async function expectBlunt404(res: Response) {
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body).toMatchObject({ error: "Not found" });
|
||||
expect(body.meta?.errorCode).toBe("cycle.feed.notFound");
|
||||
expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow");
|
||||
}
|
||||
|
||||
describe("serveCycleFeed", () => {
|
||||
it("serves the calendar with its validator and privacy headers", async () => {
|
||||
const res = await fetchFeed();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toBe(
|
||||
"text/calendar; charset=utf-8",
|
||||
);
|
||||
expect(res.headers.get("cache-control")).toBe(
|
||||
"private, no-cache, max-age=0",
|
||||
);
|
||||
expect(res.headers.get("etag")).toBe('"new-hash"');
|
||||
expect(res.headers.get("referrer-policy")).toBe("no-referrer");
|
||||
expect(await res.text()).toContain("BEGIN:VCALENDAR");
|
||||
});
|
||||
|
||||
it("looks the token up by its HMAC, live rows only", async () => {
|
||||
await fetchFeed();
|
||||
expect(db.cycleCalendarFeed.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { tokenHash: hashToken(TOKEN), revokedAt: null },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("takes the anonymous limit before any lookup", async () => {
|
||||
limits.surface.mockResolvedValue({ ...ALLOWED, allowed: false, ip: null });
|
||||
const res = await fetchFeed();
|
||||
expect(res.status).toBe(429);
|
||||
expect(db.cycleCalendarFeed.findFirst).not.toHaveBeenCalled();
|
||||
expect(limits.surface).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"cycle-feed",
|
||||
120,
|
||||
3_600_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("throttles one link through its own bucket", async () => {
|
||||
limits.token.mockResolvedValue({ ...ALLOWED, allowed: false });
|
||||
const res = await fetchFeed();
|
||||
expect(res.status).toBe(429);
|
||||
expect(limits.token).toHaveBeenCalledWith(
|
||||
`cycle-feed:${hashToken(TOKEN)}`,
|
||||
30,
|
||||
3_600_000,
|
||||
);
|
||||
expect(built.build).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers a malformed token without a lookup", async () => {
|
||||
await expectBlunt404(await fetchFeed("hlf_short.ics"));
|
||||
await expectBlunt404(await fetchFeed(`hlk_${"ab".repeat(24)}`));
|
||||
expect(db.cycleCalendarFeed.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"unknown or revoked",
|
||||
() => db.cycleCalendarFeed.findFirst.mockResolvedValue(null),
|
||||
],
|
||||
[
|
||||
"the record pending deletion or gone",
|
||||
() => db.user.findFirst.mockResolvedValue(null),
|
||||
],
|
||||
[
|
||||
"the operator's external-access switch off",
|
||||
() => gates.api.mockResolvedValue(false),
|
||||
],
|
||||
["cycle tracking off", () => gates.cycle.mockResolvedValue(false)],
|
||||
])("answers the same 404 for %s", async (_label, arrange) => {
|
||||
arrange();
|
||||
await expectBlunt404(await fetchFeed());
|
||||
expect(built.build).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("a feed a delegate minted", () => {
|
||||
beforeEach(() => {
|
||||
db.cycleCalendarFeed.findFirst.mockResolvedValue({
|
||||
...FEED_ROW,
|
||||
createdById: "manager",
|
||||
});
|
||||
});
|
||||
|
||||
const grant = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "g",
|
||||
access: "MANAGE",
|
||||
acceptedAt: new Date("2026-01-01"),
|
||||
revokedAt: null,
|
||||
expiresAt: null,
|
||||
scopeJson: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("is served while the manager's MANAGE grant over cycle is live", async () => {
|
||||
gates.grant.mockResolvedValue(grant());
|
||||
expect((await fetchFeed()).status).toBe(200);
|
||||
expect(gates.grant).toHaveBeenCalledWith({
|
||||
grantorId: "subject",
|
||||
granteeId: "manager",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["no live grant", null],
|
||||
["a WRITE grant", grant({ access: "WRITE" })],
|
||||
["a READ grant", grant({ access: "READ" })],
|
||||
[
|
||||
"a grant scoped away from cycle",
|
||||
grant({ scopeJson: ["measurements"] }),
|
||||
],
|
||||
])("is a blunt 404 with %s", async (_label, row) => {
|
||||
gates.grant.mockResolvedValue(row);
|
||||
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 },
|
||||
);
|
||||
await expectBlunt404(await fetchFeed());
|
||||
});
|
||||
});
|
||||
|
||||
it("moves the stamp and the hash only when the content moved", async () => {
|
||||
await fetchFeed();
|
||||
expect(db.cycleCalendarFeed.update).toHaveBeenCalledWith({
|
||||
where: { id: "feed-1" },
|
||||
data: expect.objectContaining({
|
||||
bodyHash: "new-hash",
|
||||
contentChangedAt: expect.any(Date),
|
||||
fetchCount: { increment: 1 },
|
||||
}),
|
||||
});
|
||||
|
||||
vi.clearAllMocks();
|
||||
db.cycleCalendarFeed.findFirst.mockResolvedValue({
|
||||
...FEED_ROW,
|
||||
bodyHash: "new-hash",
|
||||
});
|
||||
db.cycleCalendarFeed.update.mockResolvedValue({});
|
||||
const res = await fetchFeed();
|
||||
const data = db.cycleCalendarFeed.update.mock.calls[0][0].data;
|
||||
expect(data).not.toHaveProperty("bodyHash");
|
||||
expect(data).not.toHaveProperty("contentChangedAt");
|
||||
expect(await res.text()).toContain("DTSTAMP:20261101T000000Z");
|
||||
});
|
||||
|
||||
it("answers 304 to a matching If-None-Match and still counts the fetch", async () => {
|
||||
db.cycleCalendarFeed.findFirst.mockResolvedValue({
|
||||
...FEED_ROW,
|
||||
bodyHash: "new-hash",
|
||||
});
|
||||
const res = await fetchFeed(undefined, { "if-none-match": '"new-hash"' });
|
||||
expect(res.status).toBe(304);
|
||||
expect(res.headers.get("etag")).toBe('"new-hash"');
|
||||
expect(await res.text()).toBe("");
|
||||
expect(db.cycleCalendarFeed.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the User-Agent short", async () => {
|
||||
await fetchFeed(undefined, {
|
||||
"user-agent": `Google-Calendar-Importer ${"x".repeat(200)}`,
|
||||
});
|
||||
const data = db.cycleCalendarFeed.update.mock.calls[0][0].data;
|
||||
expect(data.lastFetcherUa.length).toBe(80);
|
||||
});
|
||||
|
||||
it("does not fail the fetch when the statistics write fails", async () => {
|
||||
db.cycleCalendarFeed.update.mockRejectedValue(new Error("db down"));
|
||||
expect((await fetchFeed()).status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Build one calendar feed's body from the record's rows, live (#1350).
|
||||
*
|
||||
* Nothing is read from the persisted `CyclePrediction`: it can be a day
|
||||
* stale, and the feed is computed on every fetch precisely so HealthLog stays
|
||||
* the only source of truth. The day-log window is the calendar's default
|
||||
* view, so the feed agrees with what the calendar page shows.
|
||||
*
|
||||
* `today`, the time zone and the language are the RECORD's stored settings,
|
||||
* never the request's. A calendar provider fetches from a data centre, in a
|
||||
* zone and with an Accept-Language that have nothing to do with the person.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
defaultCalendarDayLogStart,
|
||||
loadCycleEngineInputs,
|
||||
loadFlowDays,
|
||||
} from "@/lib/cycle/load-inputs";
|
||||
import {
|
||||
gatedPrediction,
|
||||
toCycleInputs,
|
||||
toProfileInput,
|
||||
} from "@/lib/cycle/engine-adapter";
|
||||
import { goalAllowsFertileWindow, toCyclePredictionDTO } from "@/lib/cycle/dto";
|
||||
import { projectCycles, type ProjectionState } from "@/lib/cycle/projection";
|
||||
import type { CycleProfileInput } from "@/lib/cycle/types";
|
||||
import { defaultLocale, isLocale, type Locale } from "@/lib/i18n/config";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { moodDateKey } from "@/lib/mood/date-key";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
import { icsContentHash, type IcsCalendar } from "../ics";
|
||||
import { buildFeedCalendar, type FeedTitleMode } from "./events";
|
||||
import { historyPeriods, historyWindowStart } from "./history";
|
||||
|
||||
/** The feed options the body depends on. */
|
||||
export interface FeedOptions {
|
||||
id: string;
|
||||
horizonCycles: number;
|
||||
historyCycles: number;
|
||||
includeFertile: boolean;
|
||||
titleMode: FeedTitleMode;
|
||||
}
|
||||
|
||||
/** The record the feed serves, as far as building it needs. */
|
||||
export interface FeedSubject {
|
||||
id: string;
|
||||
timezone: string | null;
|
||||
locale: string | null;
|
||||
}
|
||||
|
||||
export interface BuiltFeed {
|
||||
calendar: IcsCalendar;
|
||||
/** SHA-256 of the body without its stamps. Doubles as the ETag. */
|
||||
contentHash: string;
|
||||
projectionState: ProjectionState;
|
||||
}
|
||||
|
||||
/**
|
||||
* The language the feed's descriptive titles use: the record's stored one, or
|
||||
* the instance default. Deliberately not `resolveServerLocale`, which would
|
||||
* fall back to the request's cookies and Accept-Language — the provider's,
|
||||
* not the person's.
|
||||
*/
|
||||
export function feedLocale(stored: string | null): Locale {
|
||||
return isLocale(stored) ? stored : defaultLocale;
|
||||
}
|
||||
|
||||
/** The engine's defaults, for a record whose profile row was never created. */
|
||||
const DEFAULT_PROFILE: CycleProfileInput = {
|
||||
goal: "GENERAL_HEALTH",
|
||||
typicalCycleLength: null,
|
||||
typicalPeriodLength: null,
|
||||
lutealPhaseLength: null,
|
||||
predictionEnabled: true,
|
||||
rawChartMode: false,
|
||||
};
|
||||
|
||||
export async function buildCycleFeed(
|
||||
feed: FeedOptions,
|
||||
subject: FeedSubject,
|
||||
now: Date = new Date(),
|
||||
): Promise<BuiltFeed> {
|
||||
const tz = subject.timezone ?? DEFAULT_TIMEZONE;
|
||||
const today = moodDateKey(now, tz);
|
||||
|
||||
const [profileRow, inputs] = await Promise.all([
|
||||
prisma.cycleProfile.findUnique({ where: { userId: subject.id } }),
|
||||
loadCycleEngineInputs({
|
||||
userId: subject.id,
|
||||
today,
|
||||
tz,
|
||||
dayLogsFrom: defaultCalendarDayLogStart(today),
|
||||
}),
|
||||
]);
|
||||
const profile = profileRow ? toProfileInput(profileRow) : DEFAULT_PROFILE;
|
||||
|
||||
const prediction = gatedPrediction(profile, inputs, today);
|
||||
const projection = projectCycles({
|
||||
prediction,
|
||||
cycles: toCycleInputs(inputs.cycles),
|
||||
dayLogs: inputs.dayLogs,
|
||||
goal: profile.goal,
|
||||
today,
|
||||
horizon: feed.horizonCycles,
|
||||
});
|
||||
|
||||
// The fertile window: the next cycle's, for a conception goal that asked
|
||||
// for it, and read off the gated DTO rather than the raw result so the
|
||||
// cold-start suppression applies here exactly as it does on every other
|
||||
// surface. Never for AVOID_PREGNANCY: a calendar event cannot carry the
|
||||
// "not a contraceptive method" caveat, and a fertile window without it is
|
||||
// the one thing this feature must not hand out.
|
||||
let fertile: { start: string; end: string } | null = null;
|
||||
if (
|
||||
feed.includeFertile &&
|
||||
profile.goal === "TRYING_TO_CONCEIVE" &&
|
||||
prediction !== null &&
|
||||
projection.cycles.length > 0
|
||||
) {
|
||||
const dto = toCyclePredictionDTO(
|
||||
prediction,
|
||||
goalAllowsFertileWindow(profile.goal),
|
||||
"",
|
||||
);
|
||||
if (dto.fertileWindowStart && dto.fertileWindowEnd) {
|
||||
fertile = { start: dto.fertileWindowStart, end: dto.fertileWindowEnd };
|
||||
}
|
||||
}
|
||||
|
||||
let history: ReturnType<typeof historyPeriods> = [];
|
||||
const historyFrom = historyWindowStart(
|
||||
inputs.cycles,
|
||||
today,
|
||||
feed.historyCycles,
|
||||
);
|
||||
if (historyFrom !== null) {
|
||||
const flowDays = await loadFlowDays(subject.id, historyFrom);
|
||||
history = historyPeriods(
|
||||
inputs.cycles,
|
||||
flowDays,
|
||||
today,
|
||||
feed.historyCycles,
|
||||
);
|
||||
}
|
||||
|
||||
const calendar = buildFeedCalendar({
|
||||
feedId: feed.id,
|
||||
titleMode: feed.titleMode,
|
||||
projected: projection.cycles,
|
||||
history,
|
||||
fertile,
|
||||
translator: getServerTranslator(feedLocale(subject.locale)),
|
||||
});
|
||||
|
||||
return {
|
||||
calendar,
|
||||
contentHash: icsContentHash(calendar),
|
||||
projectionState: projection.state,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* The calendar feed's events, from the projection, the record's history and
|
||||
* (for a conception goal that asked for it) the next fertile window (#1350).
|
||||
*
|
||||
* Pure. What an event may carry is decided here and nowhere else, and it is
|
||||
* dates and a title: never a symptom, a note, a flow level, a temperature, a
|
||||
* test result, the intent fields or anything else the day log holds. The
|
||||
* content-leak guard (`cycle-feed-content-guard.test.ts`) seeds every one of
|
||||
* those and asserts the rendered body contains none of them.
|
||||
*
|
||||
* DISCREET (the default) titles every event with a dot and names the calendar
|
||||
* "HealthLog", so a glance at a shared screen or a lock-screen notification
|
||||
* says nothing. DESCRIPTIVE names the period, in the record's own language.
|
||||
*/
|
||||
import { addDays, dayDiff } from "@/lib/cycle/day-math";
|
||||
import type { ProjectedCycle } from "@/lib/cycle/projection";
|
||||
import { pluralKey } from "@/lib/i18n/plural";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import type { ServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
import { eventUid, type IcsCalendar, type IcsEvent } from "../ics";
|
||||
|
||||
export type FeedTitleMode = "DISCREET" | "DESCRIPTIVE";
|
||||
|
||||
/** The discreet title: one dot, no word. */
|
||||
export const DISCREET_SUMMARY = "●";
|
||||
/** The discreet calendar name. */
|
||||
export const DISCREET_CALENDAR_NAME = "HealthLog";
|
||||
|
||||
/** One logged period, as far as the feed draws it. */
|
||||
export interface HistoryPeriod {
|
||||
cycleId: string;
|
||||
/** First bleeding day. */
|
||||
start: string;
|
||||
/** Last bleeding day, inclusive. */
|
||||
end: string;
|
||||
}
|
||||
|
||||
export interface FeedEventInput {
|
||||
feedId: string;
|
||||
titleMode: FeedTitleMode;
|
||||
projected: readonly ProjectedCycle[];
|
||||
history: readonly HistoryPeriod[];
|
||||
/** The next fertile window, already gated; null when not shown. */
|
||||
fertile: { start: string; end: string } | null;
|
||||
translator: ServerTranslator;
|
||||
}
|
||||
|
||||
/** "12 Nov" in the record's language, for the descriptive description. */
|
||||
function shortDate(day: string, translator: ServerTranslator): string {
|
||||
return new Intl.DateTimeFormat(resolveIntlLocale(translator.locale), {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(`${day}T12:00:00Z`));
|
||||
}
|
||||
|
||||
/** "±3 days", pluralised in the record's language. */
|
||||
function bandText(days: number, translator: ServerTranslator): string {
|
||||
return translator.t(
|
||||
pluralKey("cycle.feed.ics.band", days, translator.locale),
|
||||
{ count: days },
|
||||
);
|
||||
}
|
||||
|
||||
function forecastEvent(p: ProjectedCycle, input: FeedEventInput): IcsEvent {
|
||||
const { translator } = input;
|
||||
const band = dayDiff(p.bandEnd, p.start);
|
||||
const descriptive = input.titleMode === "DESCRIPTIVE";
|
||||
return {
|
||||
uid: eventUid(input.feedId, `pred:${p.k}`),
|
||||
// The whole window the period may fall in: earliest likely start through
|
||||
// the last bleeding day of the latest likely start. DTEND is exclusive.
|
||||
start: p.bandStart,
|
||||
endExclusive: addDays(p.bandEnd, p.periodLength),
|
||||
summary: descriptive
|
||||
? translator.t("cycle.feed.ics.periodEstimate")
|
||||
: DISCREET_SUMMARY,
|
||||
description: descriptive
|
||||
? translator.t("cycle.feed.ics.likelyStart", {
|
||||
date: shortDate(p.start, translator),
|
||||
band: bandText(band, translator),
|
||||
})
|
||||
: bandText(band, translator),
|
||||
};
|
||||
}
|
||||
|
||||
function historyEvent(h: HistoryPeriod, input: FeedEventInput): IcsEvent {
|
||||
return {
|
||||
uid: eventUid(input.feedId, `obs:${h.cycleId}`),
|
||||
start: h.start,
|
||||
endExclusive: addDays(h.end, 1),
|
||||
summary:
|
||||
input.titleMode === "DESCRIPTIVE"
|
||||
? input.translator.t("cycle.feed.ics.periodLogged")
|
||||
: DISCREET_SUMMARY,
|
||||
};
|
||||
}
|
||||
|
||||
function fertileEvent(
|
||||
window: { start: string; end: string },
|
||||
input: FeedEventInput,
|
||||
): IcsEvent {
|
||||
return {
|
||||
uid: eventUid(input.feedId, "fertile:1"),
|
||||
start: window.start,
|
||||
endExclusive: addDays(window.end, 1),
|
||||
summary:
|
||||
input.titleMode === "DESCRIPTIVE"
|
||||
? input.translator.t("cycle.feed.ics.fertileEstimate")
|
||||
: DISCREET_SUMMARY,
|
||||
};
|
||||
}
|
||||
|
||||
/** The whole calendar the feed serves, events in date order. */
|
||||
export function buildFeedCalendar(input: FeedEventInput): IcsCalendar {
|
||||
const events: IcsEvent[] = [
|
||||
...input.history.map((h) => historyEvent(h, input)),
|
||||
...input.projected.map((p) => forecastEvent(p, input)),
|
||||
...(input.fertile ? [fertileEvent(input.fertile, input)] : []),
|
||||
].sort((a, b) => dayDiff(a.start, b.start) || a.uid.localeCompare(b.uid));
|
||||
return {
|
||||
name:
|
||||
input.titleMode === "DESCRIPTIVE"
|
||||
? input.translator.t("cycle.feed.ics.calendarName")
|
||||
: DISCREET_CALENDAR_NAME,
|
||||
events,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Which calendar app last fetched a feed, from its User-Agent (#1350).
|
||||
*
|
||||
* Only a family is ever published: the settings card says "by Google
|
||||
* Calendar", and the wide event counts conditional fetches per family. The
|
||||
* stored User-Agent itself stays on the row, truncated, and never leaves the
|
||||
* server.
|
||||
*/
|
||||
export type FetcherFamily = "GOOGLE" | "APPLE" | "MICROSOFT" | "OTHER";
|
||||
|
||||
const FAMILIES: ReadonlyArray<[FetcherFamily, RegExp]> = [
|
||||
["GOOGLE", /google/i],
|
||||
// iOS and macOS fetch through `dataaccessd` / `CalendarAgent`; older macOS
|
||||
// still says `iCal`.
|
||||
["APPLE", /dataaccessd|calendaragent|ical\/|\bios\/|macos\/|mac os x/i],
|
||||
["MICROSOFT", /microsoft|outlook|exchange/i],
|
||||
];
|
||||
|
||||
export function fetcherFamily(ua: string | null): FetcherFamily | null {
|
||||
if (!ua) return null;
|
||||
for (const [family, re] of FAMILIES) if (re.test(ua)) return family;
|
||||
return "OTHER";
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The logged periods the calendar feed may include as history (#1350).
|
||||
*
|
||||
* Pure. A period runs from the cycle's start to its `periodEndDate` when one
|
||||
* is recorded, otherwise across its observed bleeding days (the engine's own
|
||||
* §2 run rule), otherwise it is the start day alone. It never runs past today
|
||||
* or into the next cycle.
|
||||
*/
|
||||
import { addDays, dayDiff } from "@/lib/cycle/day-math";
|
||||
import { observedPeriodLength } from "@/lib/cycle/prediction";
|
||||
import type { DayLogInput, FlowLevel } from "@/lib/cycle/types";
|
||||
|
||||
import type { HistoryPeriod } from "./events";
|
||||
|
||||
export interface HistoryCycleRow {
|
||||
id: string;
|
||||
startDate: string;
|
||||
periodEndDate: string | null;
|
||||
}
|
||||
|
||||
/** The earliest start of the last `count` cycles at or before today. */
|
||||
export function historyWindowStart(
|
||||
cycles: readonly HistoryCycleRow[],
|
||||
today: string,
|
||||
count: number,
|
||||
): string | null {
|
||||
return recent(cycles, today, count)[0]?.startDate ?? null;
|
||||
}
|
||||
|
||||
function recent(
|
||||
cycles: readonly HistoryCycleRow[],
|
||||
today: string,
|
||||
count: number,
|
||||
): HistoryCycleRow[] {
|
||||
if (count <= 0) return [];
|
||||
return [...cycles]
|
||||
.filter((c) => dayDiff(today, c.startDate) >= 0)
|
||||
.sort((a, b) => dayDiff(a.startDate, b.startDate))
|
||||
.slice(-count);
|
||||
}
|
||||
|
||||
export function historyPeriods(
|
||||
cycles: readonly HistoryCycleRow[],
|
||||
flowDays: ReadonlyArray<{ date: string; flow: FlowLevel | null }>,
|
||||
today: string,
|
||||
count: number,
|
||||
): HistoryPeriod[] {
|
||||
const chosen = recent(cycles, today, count);
|
||||
const sortedStarts = [...cycles]
|
||||
.map((c) => c.startDate)
|
||||
.sort((a, b) => dayDiff(a, b));
|
||||
const logs: DayLogInput[] = flowDays.map((d) => ({
|
||||
date: d.date,
|
||||
flow: d.flow,
|
||||
basalBodyTempC: null,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
}));
|
||||
|
||||
return chosen.map((c) => {
|
||||
let end = c.startDate;
|
||||
if (c.periodEndDate && dayDiff(c.periodEndDate, c.startDate) >= 0) {
|
||||
end = c.periodEndDate;
|
||||
} else {
|
||||
const observed = observedPeriodLength(c.startDate, logs);
|
||||
if (observed > 1) end = addDays(c.startDate, observed - 1);
|
||||
}
|
||||
const nextStart = sortedStarts.find((s) => dayDiff(s, c.startDate) > 0);
|
||||
if (nextStart && dayDiff(end, nextStart) >= 0) end = addDays(nextStart, -1);
|
||||
if (dayDiff(end, today) > 0) end = today;
|
||||
return { cycleId: c.id, start: c.startDate, end };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Pieces the calendar-feed management routes share (#1350).
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
import { apiError } from "@/lib/api-response";
|
||||
|
||||
import { feedPath } from "./token";
|
||||
|
||||
/** Mint and rotate, per acting account. */
|
||||
export const FEED_MINT_LIMIT_PER_HOUR = 10;
|
||||
export const FEED_MINT_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
/** The refusal a Bearer caller gets: management is a web surface. */
|
||||
export function cookieOnlyRefusal(): Response {
|
||||
return apiError("Calendar subscriptions are managed on the web", 403, {
|
||||
errorCode: "cycle.feed.cookie_only",
|
||||
});
|
||||
}
|
||||
|
||||
export function noLiveFeed(): Response {
|
||||
return apiError("No calendar subscription is on", 404, {
|
||||
errorCode: "cycle.feed.none",
|
||||
});
|
||||
}
|
||||
|
||||
export function feedExists(): Response {
|
||||
return apiError(
|
||||
"A calendar subscription is already on. Rotate it for a new link.",
|
||||
409,
|
||||
{ errorCode: "cycle.feed.exists" },
|
||||
);
|
||||
}
|
||||
|
||||
/** The two forms of the link, built once, at mint. */
|
||||
export function feedLinks(
|
||||
origin: string,
|
||||
raw: string,
|
||||
): { url: string; webcalUrl: string } {
|
||||
const url = `${origin}${feedPath(raw)}`;
|
||||
return { url, webcalUrl: url.replace(/^https?:/, "webcal:") };
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* `GET /api/cycle/feed/<hlf_token>` — the anonymous calendar-feed fetch
|
||||
* (#1350).
|
||||
*
|
||||
* The token in the path is the whole credential. A calendar provider polls it
|
||||
* from its own servers with no cookie and no header we could ask for, which is
|
||||
* why it is a URL credential at all. Everything it may do is decided here, on
|
||||
* every fetch, in this order:
|
||||
*
|
||||
* 1. The anonymous auth-surface limit, before anything is looked up, so a
|
||||
* prober walking token space pays for every guess (the webhook rule:
|
||||
* limit before verification).
|
||||
* 2. The token's shape. Anything that is not `hlf_` plus 48 hex is not
|
||||
* looked up at all.
|
||||
* 3. The HMAC lookup of a live (unrevoked) feed.
|
||||
* 4. The token's own bucket, so one leaked link cannot grind the engine.
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* nothing from the difference because there is none.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { activeAccount } from "@/lib/account-deletion/active-account";
|
||||
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { runDetached } from "@/lib/logging/detached";
|
||||
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
|
||||
import {
|
||||
checkAuthSurfaceRateLimit,
|
||||
checkRateLimit,
|
||||
rateLimitHeaders,
|
||||
} from "@/lib/rate-limit";
|
||||
import {
|
||||
findActiveGrant,
|
||||
grantAllows,
|
||||
grantCoversDomain,
|
||||
} from "@/lib/sharing/grant-access";
|
||||
|
||||
import { renderIcs } from "../ics";
|
||||
import { buildCycleFeed, type FeedSubject } from "./build";
|
||||
import { fetcherFamily } from "./fetcher";
|
||||
import { parseFeedTokenSegment } from "./token";
|
||||
|
||||
/**
|
||||
* Per source address, across every feed. Generous because the big calendar
|
||||
* providers poll from a pool of addresses that churns; the per-token bucket
|
||||
* below is the one that bounds a single link.
|
||||
*/
|
||||
const SURFACE_LIMIT_PER_HOUR = 120;
|
||||
/** Per link. A provider polling every few minutes still fits. */
|
||||
const TOKEN_LIMIT_PER_HOUR = 30;
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
/** The longest User-Agent kept for the status read. */
|
||||
const UA_MAX_LENGTH = 80;
|
||||
|
||||
/** Headers every answer carries, the 404 included. */
|
||||
function baseHeaders(): Record<string, string> {
|
||||
return {
|
||||
"X-Robots-Tag": "noindex, nofollow",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
};
|
||||
}
|
||||
|
||||
/** One blunt 404 for every miss class. */
|
||||
function notFound(reason: string): Response {
|
||||
annotate({ meta: { feed_miss: reason } });
|
||||
const response = apiError("Not found", 404, {
|
||||
errorCode: "cycle.feed.notFound",
|
||||
});
|
||||
for (const [k, v] of Object.entries(baseHeaders()))
|
||||
response.headers.set(k, v);
|
||||
response.headers.set("Cache-Control", "no-store");
|
||||
return response;
|
||||
}
|
||||
|
||||
function tooMany(snapshot: Parameters<typeof rateLimitHeaders>[0]): Response {
|
||||
const response = apiError("Too many requests. Try again later.", 429, {
|
||||
errorCode: "cycle.feed.rateLimited",
|
||||
});
|
||||
for (const [k, v] of Object.entries({
|
||||
...baseHeaders(),
|
||||
...rateLimitHeaders(snapshot),
|
||||
})) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const FEED_SELECT = {
|
||||
id: true,
|
||||
userId: true,
|
||||
createdById: true,
|
||||
bodyHash: true,
|
||||
contentChangedAt: true,
|
||||
horizonCycles: true,
|
||||
historyCycles: true,
|
||||
includeFertile: true,
|
||||
titleMode: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Whether a feed may still be served. Every condition is read on this fetch;
|
||||
* nothing is cached on the row, so a revocation of anything the feed rests on
|
||||
* lands on the provider's next poll.
|
||||
*/
|
||||
async function feedStillAllowed(feed: {
|
||||
userId: string;
|
||||
createdById: string;
|
||||
}): Promise<
|
||||
{ ok: true; subject: FeedSubject } | { ok: false; reason: string }
|
||||
> {
|
||||
const [subject, apiOn, cycleOn] = await Promise.all([
|
||||
prisma.user.findFirst({
|
||||
where: activeAccount({ id: feed.userId }),
|
||||
select: { id: true, timezone: true, locale: true },
|
||||
}),
|
||||
isApiGloballyEnabled(),
|
||||
isCycleAvailableForUser(feed.userId),
|
||||
]);
|
||||
if (!subject) return { ok: false, reason: "subject" };
|
||||
if (!apiOn) return { ok: false, reason: "api_disabled" };
|
||||
if (!cycleOn) return { ok: false, reason: "cycle_disabled" };
|
||||
|
||||
if (feed.createdById !== feed.userId) {
|
||||
const [carrier, grant] = await Promise.all([
|
||||
prisma.user.findFirst({
|
||||
where: activeAccount({ id: feed.createdById }),
|
||||
select: { id: true },
|
||||
}),
|
||||
findActiveGrant({ grantorId: feed.userId, granteeId: feed.createdById }),
|
||||
]);
|
||||
if (!carrier) return { ok: false, reason: "carrier" };
|
||||
if (!grant || !grantAllows(grant, "manage")) {
|
||||
return { ok: false, reason: "grant" };
|
||||
}
|
||||
if (!grantCoversDomain(grant, "cycle"))
|
||||
return { ok: false, reason: "grant" };
|
||||
}
|
||||
return { ok: true, subject };
|
||||
}
|
||||
|
||||
/** Does the request's `If-None-Match` name this ETag? */
|
||||
function matchesEtag(request: NextRequest, etag: string): boolean {
|
||||
const header = request.headers.get("if-none-match");
|
||||
if (!header) return false;
|
||||
return header
|
||||
.split(",")
|
||||
.map((v) => v.trim().replace(/^W\//, ""))
|
||||
.some((v) => v === etag || v === "*");
|
||||
}
|
||||
|
||||
export async function serveCycleFeed(
|
||||
request: NextRequest,
|
||||
segment: string,
|
||||
): Promise<Response> {
|
||||
annotate({ action: { name: "cycle.feed.fetch" } });
|
||||
|
||||
// 1. Before any lookup.
|
||||
const surface = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"cycle-feed",
|
||||
SURFACE_LIMIT_PER_HOUR,
|
||||
HOUR_MS,
|
||||
);
|
||||
if (!surface.allowed) return tooMany(surface);
|
||||
|
||||
// 2. Shape.
|
||||
const token = parseFeedTokenSegment(segment);
|
||||
if (token === null) return notFound("malformed");
|
||||
|
||||
// 3. Lookup of a live feed.
|
||||
const tokenHash = hashToken(token);
|
||||
const feed = await prisma.cycleCalendarFeed.findFirst({
|
||||
where: { tokenHash, revokedAt: null },
|
||||
select: FEED_SELECT,
|
||||
});
|
||||
if (!feed) return notFound("unknown");
|
||||
|
||||
// 4. The link's own bucket.
|
||||
const perToken = await checkRateLimit(
|
||||
`cycle-feed:${tokenHash}`,
|
||||
TOKEN_LIMIT_PER_HOUR,
|
||||
HOUR_MS,
|
||||
);
|
||||
if (!perToken.allowed) return tooMany(perToken);
|
||||
|
||||
// 5. Gates.
|
||||
const allowed = await feedStillAllowed(feed);
|
||||
if (!allowed.ok) return notFound(allowed.reason);
|
||||
|
||||
// 6. The body, built live.
|
||||
const now = new Date();
|
||||
const built = await buildCycleFeed(feed, allowed.subject, now);
|
||||
const changed = built.contentHash !== feed.bodyHash;
|
||||
const changedAt = changed ? now : feed.contentChangedAt;
|
||||
const etag = `"${built.contentHash}"`;
|
||||
const conditional = request.headers.has("if-none-match");
|
||||
const notModified = !changed && matchesEtag(request, etag);
|
||||
|
||||
const ua = request.headers.get("user-agent")?.slice(0, UA_MAX_LENGTH) ?? null;
|
||||
trackBackgroundTask(
|
||||
runDetached(() =>
|
||||
prisma.cycleCalendarFeed
|
||||
.update({
|
||||
where: { id: feed.id },
|
||||
data: {
|
||||
lastFetchedAt: now,
|
||||
fetchCount: { increment: 1 },
|
||||
// A fetch without a User-Agent keeps the last one we know.
|
||||
...(ua ? { lastFetcherUa: ua } : {}),
|
||||
...(changed
|
||||
? { bodyHash: built.contentHash, contentChangedAt: changedAt }
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
.then(() => undefined)
|
||||
.catch(() => {
|
||||
// The statistics are best-effort; a failed write never fails the
|
||||
// fetch, and the next fetch writes them again.
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
status: notModified ? 304 : 200,
|
||||
conditional,
|
||||
uaFamily: fetcherFamily(ua),
|
||||
projection_state: built.projectionState,
|
||||
events: built.calendar.events.length,
|
||||
},
|
||||
});
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...baseHeaders(),
|
||||
// `no-cache`, not `no-store`: a client keeps the body and its validator
|
||||
// and asks again with `If-None-Match`, which is what the 304 path is for.
|
||||
"Cache-Control": "private, no-cache, max-age=0",
|
||||
ETag: etag,
|
||||
};
|
||||
if (notModified) return new Response(null, { status: 304, headers });
|
||||
return new Response(renderIcs(built.calendar, changedAt), {
|
||||
status: 200,
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
"Content-Disposition": 'inline; filename="healthlog.ics"',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* What the settings card may know about a record's calendar feed (#1350).
|
||||
*
|
||||
* The token's prefix, the options and the fetch statistics. Never the hash
|
||||
* and never the URL: the URL is returned once, by the mint, and after that it
|
||||
* exists only where the person pasted it.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { ProjectionState } from "@/lib/cycle/projection";
|
||||
|
||||
import { buildCycleFeed, type FeedSubject } from "./build";
|
||||
import { fetcherFamily, type FetcherFamily } from "./fetcher";
|
||||
import type { FeedTitleMode } from "./events";
|
||||
|
||||
export const FEED_SUMMARY_SELECT = {
|
||||
id: true,
|
||||
tokenPrefix: true,
|
||||
createdAt: true,
|
||||
lastFetchedAt: true,
|
||||
fetchCount: true,
|
||||
lastFetcherUa: true,
|
||||
horizonCycles: true,
|
||||
historyCycles: true,
|
||||
includeFertile: true,
|
||||
titleMode: true,
|
||||
} as const;
|
||||
|
||||
export interface FeedSummaryRow {
|
||||
id: string;
|
||||
tokenPrefix: string;
|
||||
createdAt: Date;
|
||||
lastFetchedAt: Date | null;
|
||||
fetchCount: number;
|
||||
lastFetcherUa: string | null;
|
||||
horizonCycles: number;
|
||||
historyCycles: number;
|
||||
includeFertile: boolean;
|
||||
titleMode: FeedTitleMode;
|
||||
}
|
||||
|
||||
export interface CycleFeedSummary {
|
||||
tokenPrefix: string;
|
||||
createdAt: string;
|
||||
lastFetchedAt: string | null;
|
||||
fetchCount: number;
|
||||
/** Which calendar app fetched last, as a family. Never the raw UA. */
|
||||
lastFetcher: FetcherFamily | null;
|
||||
horizonCycles: number;
|
||||
historyCycles: number;
|
||||
includeFertile: boolean;
|
||||
titleMode: FeedTitleMode;
|
||||
}
|
||||
|
||||
export interface CycleFeedStatus {
|
||||
/** The live feed, or null when the subscription is off. */
|
||||
feed: CycleFeedSummary | null;
|
||||
/**
|
||||
* What the live feed serves right now (OK / LEARNING / LATE / OFF), so the
|
||||
* card can say why it holds fewer cycles than asked. Null with no feed.
|
||||
*/
|
||||
projectionState: ProjectionState | null;
|
||||
/** Whether the record's goal admits the fertile-window option at all. */
|
||||
fertileAvailable: boolean;
|
||||
}
|
||||
|
||||
export function toFeedSummary(row: FeedSummaryRow): CycleFeedSummary {
|
||||
return {
|
||||
tokenPrefix: row.tokenPrefix,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
lastFetchedAt: row.lastFetchedAt?.toISOString() ?? null,
|
||||
fetchCount: row.fetchCount,
|
||||
lastFetcher: fetcherFamily(row.lastFetcherUa),
|
||||
horizonCycles: row.horizonCycles,
|
||||
historyCycles: row.historyCycles,
|
||||
includeFertile: row.includeFertile,
|
||||
titleMode: row.titleMode,
|
||||
};
|
||||
}
|
||||
|
||||
/** The record's live feed, or null. The partial index admits at most one. */
|
||||
export function findLiveFeed(userId: string): Promise<FeedSummaryRow | null> {
|
||||
return prisma.cycleCalendarFeed.findFirst({
|
||||
where: { userId, revokedAt: null },
|
||||
select: FEED_SUMMARY_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
export async function cycleFeedStatus(
|
||||
subject: FeedSubject,
|
||||
goal: string,
|
||||
): Promise<CycleFeedStatus> {
|
||||
const live = await findLiveFeed(subject.id);
|
||||
const fertileAvailable = goal === "TRYING_TO_CONCEIVE";
|
||||
if (!live) return { feed: null, projectionState: null, fertileAvailable };
|
||||
const built = await buildCycleFeed(live, subject);
|
||||
return {
|
||||
feed: toFeedSummary(live),
|
||||
projectionState: built.projectionState,
|
||||
fertileAvailable,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* The `hlf_` calendar-feed credential (#1350).
|
||||
*
|
||||
* `hlf_` plus 48 hex characters: 192 random bits, the share link's strength.
|
||||
* Only `hashToken(raw)` is stored, under the same `API_TOKEN_HMAC_KEY` as every
|
||||
* other HealthLog credential, so rotating that key ends every feed URL along
|
||||
* with every token (documented in the self-hosting guide). The raw token is
|
||||
* returned once at mint and cannot be recovered afterwards.
|
||||
*/
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
|
||||
export const FEED_TOKEN_PREFIX = "hlf_";
|
||||
|
||||
/** The exact shape a feed token has. Anything else is not looked up. */
|
||||
const FEED_TOKEN_SHAPE = /^hlf_[0-9a-f]{48}$/;
|
||||
|
||||
/** How many leading characters the settings card shows. */
|
||||
export const FEED_TOKEN_DISPLAY_PREFIX_LENGTH = 8;
|
||||
|
||||
export interface MintedFeedToken {
|
||||
raw: string;
|
||||
hash: string;
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
export function mintFeedToken(): MintedFeedToken {
|
||||
const raw = `${FEED_TOKEN_PREFIX}${randomBytes(24).toString("hex")}`;
|
||||
return {
|
||||
raw,
|
||||
hash: hashToken(raw),
|
||||
prefix: raw.slice(0, FEED_TOKEN_DISPLAY_PREFIX_LENGTH),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The token from a path segment, or null when it is not one. A trailing
|
||||
* `.ics` is accepted and stripped, because calendar apps are happier with a
|
||||
* URL that names its file type.
|
||||
*/
|
||||
export function parseFeedTokenSegment(segment: string): string | null {
|
||||
const token = segment.endsWith(".ics") ? segment.slice(0, -4) : segment;
|
||||
return FEED_TOKEN_SHAPE.test(token) ? token : null;
|
||||
}
|
||||
|
||||
/** The feed path for a raw token, relative to the instance origin. */
|
||||
export function feedPath(raw: string): string {
|
||||
return `/api/cycle/feed/${raw}.ics`;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* A small, hand-rolled RFC 5545 writer for the cycle calendar feed (#1350).
|
||||
*
|
||||
* The feed needs one shape of calendar: all-day events, a published
|
||||
* subscription, no time zones and no recurrence. That is a few dozen lines of
|
||||
* the specification, so it is written here rather than taken from a
|
||||
* dependency whose defaults (descriptions, organisers, alarms, a VTIMEZONE)
|
||||
* would each be something to switch off.
|
||||
*
|
||||
* What the specification asks for and this does:
|
||||
* * CRLF line endings (§3.1).
|
||||
* * Lines folded at 75 octets, never inside a UTF-8 sequence, continued with
|
||||
* a single space (§3.1).
|
||||
* * TEXT escaping of backslash, semicolon, comma and newline (§3.3.11).
|
||||
* * All-day events as `VALUE=DATE`, with DTEND exclusive (§3.6.1).
|
||||
*
|
||||
* Pure: no clock, no database. The caller hands in the stamp.
|
||||
*/
|
||||
import { createHash, createHmac } from "node:crypto";
|
||||
|
||||
export interface IcsEvent {
|
||||
/** Globally unique and stable across rebuilds of the same feed. */
|
||||
uid: string;
|
||||
/** First day, `YYYY-MM-DD`. */
|
||||
start: string;
|
||||
/** The day AFTER the last day, `YYYY-MM-DD` (DTEND is exclusive). */
|
||||
endExclusive: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface IcsCalendar {
|
||||
/** `X-WR-CALNAME`, the name a calendar app shows for the subscription. */
|
||||
name: string;
|
||||
events: readonly IcsEvent[];
|
||||
}
|
||||
|
||||
const CRLF = "\r\n";
|
||||
const MAX_OCTETS = 75;
|
||||
|
||||
/** The stamp a body is hashed with, so the hash never depends on the clock. */
|
||||
const HASH_STAMP = "19700101T000000Z";
|
||||
|
||||
/** §3.3.11 — escape a TEXT value. */
|
||||
export function escapeText(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/;/g, "\\;")
|
||||
.replace(/,/g, "\\,")
|
||||
.replace(/\r\n|\r|\n/g, "\\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* §3.1 — fold a content line at 75 octets. Continuation lines begin with one
|
||||
* space, which counts toward their own 75. Splits only between code points,
|
||||
* so a multibyte character is never cut in half.
|
||||
*/
|
||||
export function foldLine(line: string): string {
|
||||
const out: string[] = [];
|
||||
let current = "";
|
||||
let octets = 0;
|
||||
for (const ch of line) {
|
||||
const size = Buffer.byteLength(ch, "utf8");
|
||||
if (octets + size > MAX_OCTETS) {
|
||||
out.push(current);
|
||||
current = " ";
|
||||
octets = 1;
|
||||
}
|
||||
current += ch;
|
||||
octets += size;
|
||||
}
|
||||
out.push(current);
|
||||
return out.join(CRLF);
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DD` → `YYYYMMDD`. */
|
||||
function icsDate(day: string): string {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) {
|
||||
throw new Error(`Not a calendar day: ${day}`);
|
||||
}
|
||||
return day.replace(/-/g, "");
|
||||
}
|
||||
|
||||
/** A UTC instant as `YYYYMMDDTHHMMSSZ`. */
|
||||
export function icsStamp(at: Date): string {
|
||||
return at
|
||||
.toISOString()
|
||||
.replace(/\.\d{3}Z$/, "Z")
|
||||
.replace(/[-:]/g, "");
|
||||
}
|
||||
|
||||
const BASE32 = "abcdefghijklmnopqrstuvwxyz234567";
|
||||
|
||||
/** RFC 4648 base32, lower case, unpadded. */
|
||||
function base32(bytes: Uint8Array): string {
|
||||
let out = "";
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
for (const byte of bytes) {
|
||||
value = (value << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
out += BASE32[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) out += BASE32[(value << (5 - bits)) & 31];
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable event UID: the feed's id keys an HMAC over the slot (`obs:<cycle
|
||||
* id>` or `pred:<k>`). Stable across rebuilds of one feed, so a calendar app
|
||||
* updates an event in place; unrelated across feeds, so a rotated link starts
|
||||
* a fresh set; and it carries neither a user id nor a host.
|
||||
*/
|
||||
export function eventUid(feedId: string, slot: string): string {
|
||||
const mac = createHmac("sha256", feedId).update(slot).digest();
|
||||
return `${base32(mac.subarray(0, 20))}@healthlog.invalid`;
|
||||
}
|
||||
|
||||
function render(calendar: IcsCalendar, stamp: string): string {
|
||||
const lines: string[] = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//HealthLog//Calendar feed//EN",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"METHOD:PUBLISH",
|
||||
`X-WR-CALNAME:${escapeText(calendar.name)}`,
|
||||
"REFRESH-INTERVAL;VALUE=DURATION:PT6H",
|
||||
"X-PUBLISHED-TTL:PT6H",
|
||||
];
|
||||
for (const event of calendar.events) {
|
||||
lines.push(
|
||||
"BEGIN:VEVENT",
|
||||
`UID:${event.uid}`,
|
||||
`DTSTAMP:${stamp}`,
|
||||
`LAST-MODIFIED:${stamp}`,
|
||||
`DTSTART;VALUE=DATE:${icsDate(event.start)}`,
|
||||
`DTEND;VALUE=DATE:${icsDate(event.endExclusive)}`,
|
||||
`SUMMARY:${escapeText(event.summary)}`,
|
||||
);
|
||||
if (event.description) {
|
||||
lines.push(`DESCRIPTION:${escapeText(event.description)}`);
|
||||
}
|
||||
lines.push("TRANSP:TRANSPARENT", "CLASS:PRIVATE", "END:VEVENT");
|
||||
}
|
||||
lines.push("END:VCALENDAR");
|
||||
return lines.map(foldLine).join(CRLF) + CRLF;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SHA-256 of the calendar as it would render with a fixed stamp. Equal
|
||||
* inputs give an equal hash whatever the clock says, which is what lets the
|
||||
* feed move its stamps only when the content moved.
|
||||
*/
|
||||
export function icsContentHash(calendar: IcsCalendar): string {
|
||||
return createHash("sha256")
|
||||
.update(render(calendar, HASH_STAMP))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/** The calendar, every event stamped with `changedAt`. */
|
||||
export function renderIcs(calendar: IcsCalendar, changedAt: Date): string {
|
||||
return render(calendar, icsStamp(changedAt));
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* The absolute origin a link the app hands out is built from.
|
||||
*
|
||||
* Prefers the forwarded host and protocol a reverse proxy sets (the documented
|
||||
* self-hosting path fronts the app with Caddy, Traefik or nginx), falling back
|
||||
* to the request URL's own origin. It trusts `X-Forwarded-Host` exactly as far
|
||||
* as the share links always have: the value only ever reaches the caller who
|
||||
* sent it, in the response to their own authenticated request.
|
||||
*
|
||||
* Shared by the clinician share links and the cycle calendar feed, so the two
|
||||
* links a person copies out of the app always name the same host.
|
||||
*/
|
||||
export function resolveOrigin(request: Request): 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}`;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Edge headers for the paths whose credential is the URL itself.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* The invite landing `/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.
|
||||
*
|
||||
* The cycle calendar feed (`/api/cycle/feed/<hlf_token>`) carries its
|
||||
* credential in the path too. It is kept out of indexes and referrers like the
|
||||
* share view, but its caching is the route's own: `private, no-cache` plus an
|
||||
* ETag, so a provider can ask again with `If-None-Match` instead of
|
||||
* downloading the body on every poll. `no-store` here would throw the
|
||||
* validator away.
|
||||
*
|
||||
* Runs after the proxy's general header block, so these values win.
|
||||
*/
|
||||
export function setPathCredentialHeaders(
|
||||
pathname: string,
|
||||
headers: Headers,
|
||||
): void {
|
||||
const feed = pathname.startsWith("/api/cycle/feed/");
|
||||
const page = pathname.startsWith("/c/") || pathname.startsWith("/invite/");
|
||||
if (!feed && !page) return;
|
||||
if (page) headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
|
||||
headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||
headers.set("Referrer-Policy", "no-referrer");
|
||||
}
|
||||
@@ -127,6 +127,7 @@ describe("redactSecrets", () => {
|
||||
it.each([
|
||||
["an invite token", `hlv_${"0a".repeat(32)}`],
|
||||
["a managed-profile handover token", `hlp_${"0a".repeat(32)}`],
|
||||
["a cycle calendar-feed token", `hlf_${"0a".repeat(24)}`],
|
||||
["a session secret", `hls_${"ab".repeat(32)}`],
|
||||
["an elevation", `hle_${"cd".repeat(32)}`],
|
||||
["a native sign-in handoff code", `hlh_${"Ab9_-".repeat(9)}Ab`],
|
||||
@@ -332,6 +333,36 @@ describe("notification quick-answer token in the path", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cycle calendar-feed token in the path (#1350)", () => {
|
||||
const token = `hlf_${"5b".repeat(24)}`;
|
||||
|
||||
it("scrubs the feed token from the path, with or without .ics", () => {
|
||||
expect(redactSecrets(`/api/cycle/feed/${token}`)).toBe(
|
||||
"/api/cycle/feed/[REDACTED]",
|
||||
);
|
||||
expect(redactSecrets(`/api/cycle/feed/${token}.ics`)).toBe(
|
||||
"/api/cycle/feed/[REDACTED]",
|
||||
);
|
||||
expect(
|
||||
redactSecrets(`GET https://health.example/api/cycle/feed/${token}.ics`),
|
||||
).toBe("GET https://health.example/api/cycle/feed/[REDACTED]");
|
||||
expect(PATH_SECRET_PATHS.map((p) => p.prefix)).toContain(
|
||||
"/api/cycle/feed/",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the management namespace readable", () => {
|
||||
// A different segment, so the feed prefix (ending in `/`) never reaches
|
||||
// it: an operator reading the logs still sees which management verb ran.
|
||||
expect(redactSecrets("/api/cycle/feed-subscription")).toBe(
|
||||
"/api/cycle/feed-subscription",
|
||||
);
|
||||
expect(redactSecrets("/api/cycle/feed-subscription/rotate")).toBe(
|
||||
"/api/cycle/feed-subscription/rotate",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Prisma prints a failed call, arguments and all, into its error message and
|
||||
* stack. Those went to the logs and to the operator's GlitchTip with a
|
||||
|
||||
@@ -72,6 +72,12 @@ export const PATH_SECRET_PATHS: ReadonlyArray<{
|
||||
// single-use action token is the request's only credential and rides the
|
||||
// trailing path segment of `/api/notifications/action/<token>`.
|
||||
{ prefix: "/api/notifications/action/" },
|
||||
// #1350 — the cycle calendar feed. A calendar provider polls
|
||||
// `/api/cycle/feed/<hlf_token>.ics` with no session; the path segment is
|
||||
// the whole credential. The management routes live under
|
||||
// `/api/cycle/feed-subscription`, a different segment, so this prefix
|
||||
// (which ends in `/`) never matches them.
|
||||
{ prefix: "/api/cycle/feed/" },
|
||||
];
|
||||
|
||||
function redactPathSegments(input: string): string {
|
||||
@@ -140,12 +146,13 @@ export function redactSecrets(input: string): string {
|
||||
// HealthLog hex-bodied credentials: `hlk_` access token, `hlr_` refresh
|
||||
// token, `hls_` session cookie secret (v1.30.32) and clinician share
|
||||
// token, `hle_` step-up elevation (v1.30.34), `hlv_` registration invite,
|
||||
// `hlp_` managed-profile handover link (v1.42).
|
||||
// `hlp_` managed-profile handover link (v1.42), `hlf_` cycle
|
||||
// calendar-feed link (#1350).
|
||||
// Same boundary-aware shape as the `sk-` rule. The
|
||||
// idempotency replay-cache already rejects bodies containing
|
||||
// these prefixes (CLAUDE.md headless-client-API patterns); this
|
||||
// is the matching egress guard for log/error surfaces.
|
||||
.replace(/(^|[^A-Za-z0-9])hl[kpresv]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]")
|
||||
.replace(/(^|[^A-Za-z0-9])hl[kpresvf]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]")
|
||||
// v1.22.0 — the MCP OAuth bridge artifacts: the authorization code
|
||||
// (`hlac_`), refresh token (`hlrt_`), and DCR client id (`hlc_`). All
|
||||
// are signed, base64url-bodied, and credential-bearing, so they are
|
||||
|
||||
@@ -144,6 +144,13 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"cycle.day-log.conflict",
|
||||
"cycle.day-log.invalid",
|
||||
"cycle.disabled",
|
||||
"cycle.feed.api_disabled",
|
||||
"cycle.feed.cookie_only",
|
||||
"cycle.feed.exists",
|
||||
"cycle.feed.fertile_goal",
|
||||
"cycle.feed.none",
|
||||
"cycle.feed.notFound",
|
||||
"cycle.feed.rateLimited",
|
||||
"cycle.period.invalid",
|
||||
"cycle.symptom.custom.invalid",
|
||||
"cycle.symptom.custom.limit",
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* OpenAPI route table — the subscribable cycle forecast (#1350): the
|
||||
* anonymous `.ics` feed, its cookie-only management routes, and the
|
||||
* projection the calendar read publishes beside its forecast.
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
|
||||
* schema comes from `src/lib/validations/cycle-feed.ts`, so the wire contract
|
||||
* stays single-source.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
|
||||
import {
|
||||
cycleFeedOptionsPatchSchema,
|
||||
cycleFeedTitleModeEnum,
|
||||
} from "@/lib/validations/cycle-feed";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
malformedJsonResponse,
|
||||
recordRefusal,
|
||||
recordWriteRateLimitResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
const dayKey = () => z.string().meta({ format: "date", example: "2026-11-12" });
|
||||
|
||||
/** One projected period, as `/api/cycle/calendar` publishes it. */
|
||||
export const projectedCycleDto = z
|
||||
.object({
|
||||
k: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"1 = the next period (the engine's own forecast), 2 = the one after it, …",
|
||||
),
|
||||
start: dayKey().describe("The most likely first day."),
|
||||
periodLength: z.number().int().describe("Predicted bleeding days."),
|
||||
bandStart: dayKey().describe("The earliest likely first day."),
|
||||
bandEnd: dayKey().describe("The latest likely first day."),
|
||||
})
|
||||
.meta({
|
||||
id: "ProjectedCycleDTO",
|
||||
description:
|
||||
"A forecast period start with its band. The band widens from one cycle to the next and the list stops itself where it would pass ±10 days or half a cycle. Render it as given; never extrapolate further cycles on a client.",
|
||||
});
|
||||
|
||||
export const projectionStateEnum = z
|
||||
.enum(["OK", "LEARNING", "LATE", "OFF"])
|
||||
.meta({
|
||||
id: "CycleProjectionState",
|
||||
description:
|
||||
"Why `projection` holds what it holds. `OK`: the projected cycles. `LEARNING`: fewer than three observed cycles, so the next period only (or nothing with no logged cycle). `LATE`: the next period's band has passed with no new period logged; no forecast until one is. `OFF`: prediction is off or raw-chart mode is on.",
|
||||
});
|
||||
|
||||
const fetcherFamilyEnum = z.enum(["GOOGLE", "APPLE", "MICROSOFT", "OTHER"]);
|
||||
|
||||
const feedSummary = z
|
||||
.object({
|
||||
tokenPrefix: z
|
||||
.string()
|
||||
.describe(
|
||||
"The first 8 characters of the link's token, to tell links apart.",
|
||||
),
|
||||
createdAt: isoDateTime(),
|
||||
lastFetchedAt: isoDateTime().nullable(),
|
||||
fetchCount: z.number().int(),
|
||||
lastFetcher: fetcherFamilyEnum
|
||||
.nullable()
|
||||
.describe(
|
||||
"Which calendar app fetched last, as a family; never the raw User-Agent.",
|
||||
),
|
||||
horizonCycles: z.number().int().min(1).max(6),
|
||||
historyCycles: z.number().int().min(0).max(12),
|
||||
includeFertile: z.boolean(),
|
||||
titleMode: cycleFeedTitleModeEnum,
|
||||
})
|
||||
.meta({
|
||||
id: "CycleFeedSummary",
|
||||
description:
|
||||
"The live calendar subscription, without its secret: never the token hash and never the URL.",
|
||||
});
|
||||
|
||||
const feedStatus = z.object({
|
||||
feed: feedSummary.nullable(),
|
||||
projectionState: projectionStateEnum.nullable(),
|
||||
fertileAvailable: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether the record's goal admits the fertile-window option (TRYING_TO_CONCEIVE only).",
|
||||
),
|
||||
});
|
||||
|
||||
const feedMinted = z.object({
|
||||
url: z
|
||||
.string()
|
||||
.describe(
|
||||
"The feed URL. Returned exactly once; it cannot be read back later.",
|
||||
),
|
||||
webcalUrl: z
|
||||
.string()
|
||||
.describe(
|
||||
"The same URL with the `webcal:` scheme, which opens a calendar app's subscribe dialog.",
|
||||
),
|
||||
feed: feedSummary,
|
||||
});
|
||||
|
||||
const feedOptionsRequest = cycleFeedOptionsPatchSchema.meta({
|
||||
id: "CycleFeedOptionsPatch",
|
||||
description:
|
||||
"The writable options. At least one field. `includeFertile: true` is refused (422 `cycle.feed.fertile_goal`) unless the goal is TRYING_TO_CONCEIVE.",
|
||||
});
|
||||
|
||||
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.",
|
||||
);
|
||||
|
||||
const REPROOF_401 = {
|
||||
"401": {
|
||||
description:
|
||||
"Not signed in, or the session has no fresh proof: turning the subscription on or rotating it needs a sign-in or `POST /api/auth/reproof` within the last five minutes, answered otherwise with 401 `auth.reproof.required` and `meta.methods`.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
};
|
||||
|
||||
const mintResponses = {
|
||||
"201": {
|
||||
description: "The new link, returned exactly once.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(feedMinted, "CycleFeedMinted"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...MANAGEMENT_REFUSALS,
|
||||
...stdResponses,
|
||||
...REPROOF_401,
|
||||
};
|
||||
|
||||
export const cycleFeedPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/cycle/feed/{token}": {
|
||||
get: {
|
||||
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.",
|
||||
security: [],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
token: z
|
||||
.string()
|
||||
.describe("Raw `hlf_` feed token, optionally with `.ics`."),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The calendar. `Cache-Control: private, no-cache, max-age=0`, `ETag`.",
|
||||
content: {
|
||||
"text/calendar": { schema: z.string() },
|
||||
},
|
||||
},
|
||||
"304": {
|
||||
description: "The body is unchanged since the `If-None-Match` ETag.",
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"Flat 404 for every miss class (errorCode `cycle.feed.notFound`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
description: "The source-address or per-token bucket is exhausted.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
headers: stdResponses["429"].headers,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/cycle/feed-subscription": {
|
||||
get: {
|
||||
tags: ["Cycle"],
|
||||
summary: "Calendar subscription status (#1350)",
|
||||
description:
|
||||
"The record's live calendar subscription without its secret (prefix, options, fetch statistics) and what the feed serves right now. Never the hash, never the URL.",
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The status; `feed` is null while the subscription is off.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(feedStatus, "CycleFeedStatus"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...MANAGEMENT_REFUSALS,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Cycle"],
|
||||
summary: "Turn the calendar subscription on (#1350)",
|
||||
description:
|
||||
"Mints an `hlf_` token (192-bit), stores only its HMAC, and returns the URL EXACTLY ONCE. One live subscription per record: a second answers 409 `cycle.feed.exists` (rotate instead). Defaults: three cycles ahead, no history, no fertile window, discreet titles. Rate-limited per acting account (`cycle-feed:mint:<actorId>`, 10/h, shared with rotate).",
|
||||
responses: {
|
||||
...mintResponses,
|
||||
"409": {
|
||||
description: "A subscription is already on (`cycle.feed.exists`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Cycle"],
|
||||
summary: "Change the calendar subscription's options (#1350)",
|
||||
description:
|
||||
"Field-by-field update of the options. The URL does not change; the feed's next fetch serves the new content.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: feedOptionsRequest } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The status after the change.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(feedStatus, "CycleFeedStatusAfterPatch"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "No subscription is on (`cycle.feed.none`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...MANAGEMENT_REFUSALS,
|
||||
...malformedJsonResponse,
|
||||
...stdResponses,
|
||||
...recordWriteRateLimitResponse,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Cycle"],
|
||||
summary: "Turn the calendar subscription off (#1350)",
|
||||
description:
|
||||
"Revokes the link; calendars holding it stop updating on their next poll. Copies a calendar provider already holds are beyond the app's reach.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Revoked.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ revoked: z.literal(true) }),
|
||||
"CycleFeedRevoked",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "No subscription is on (`cycle.feed.none`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...MANAGEMENT_REFUSALS,
|
||||
...stdResponses,
|
||||
...recordWriteRateLimitResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/cycle/feed-subscription/rotate": {
|
||||
post: {
|
||||
tags: ["Cycle"],
|
||||
summary: "Replace the calendar subscription's link (#1350)",
|
||||
description:
|
||||
"One transaction: the live link is revoked and a new one minted with the same options. Returns the new URL EXACTLY ONCE. Calendars using the old link stop updating.",
|
||||
responses: {
|
||||
...mintResponses,
|
||||
"404": {
|
||||
description: "No subscription is on (`cycle.feed.none`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
listEnvelope,
|
||||
} from "./shared";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
import { projectedCycleDto, projectionStateEnum } from "./cycle-feed";
|
||||
|
||||
// ── Cycle tracking (v1.15.0) ─────────────────────────────────────────
|
||||
// The `/api/cycle` capture / calendar / history / settings surface +
|
||||
@@ -316,6 +317,12 @@ const cycleCalendarResponse = z.object({
|
||||
// population prior) — the client shows a calm "learning your cycle" state.
|
||||
// Additive + back-compatible.
|
||||
stillLearning: z.boolean(),
|
||||
// #1350 — the next cycles with widening bands, from the same engine run and
|
||||
// the same projection the calendar feed serves. Additive. Fertile fields are
|
||||
// never here; they stay in `prediction.*` behind the goal and cold-start
|
||||
// gates.
|
||||
projection: z.array(projectedCycleDto),
|
||||
projectionState: projectionStateEnum,
|
||||
days: z.array(cycleCalendarDayDto),
|
||||
meta: z.object({ generatedAt: isoDateTime() }),
|
||||
});
|
||||
|
||||
@@ -53,6 +53,7 @@ import { consentPaths } from "./consent";
|
||||
import { customMetricPaths } from "./custom-metrics";
|
||||
import { dailyPaths } from "./daily";
|
||||
import { cyclePaths } from "./cycle";
|
||||
import { cycleFeedPaths } from "./cycle-feed";
|
||||
import { dashboardWidgetPaths } from "./dashboard";
|
||||
import { dayPaths } from "./day";
|
||||
import { searchPaths } from "./search";
|
||||
@@ -100,6 +101,7 @@ import { coachPrefsSchema, withScopeRefusal } from "./shared";
|
||||
|
||||
const registeredPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...cyclePaths,
|
||||
...cycleFeedPaths,
|
||||
...metaPaths,
|
||||
...healthRecordPaths,
|
||||
...exportPaths,
|
||||
|
||||
@@ -57,6 +57,15 @@ describe("isAllowlistedApiRead — offline read boundary", () => {
|
||||
expect(isAllowlistedApiRead("/api/withings/callback")).toBe(false);
|
||||
});
|
||||
|
||||
it("never caches the cycle calendar feed or its management routes (#1350)", () => {
|
||||
// The feed URL is a credential and its body is a record's cycle dates;
|
||||
// neither belongs in a browser's durable storage.
|
||||
const feed = `/api/cycle/feed/hlf_${"1f".repeat(24)}.ics`;
|
||||
expect(isAllowlistedApiRead(feed)).toBe(false);
|
||||
expect(isAllowlistedApiRead("/api/cycle/feed-subscription")).toBe(false);
|
||||
expect(SW_SOURCE).not.toContain("/api/cycle");
|
||||
});
|
||||
|
||||
it("does not match a prefix that is only a partial path segment", () => {
|
||||
// `/api/measurements-export` must NOT match `/api/measurements`.
|
||||
expect(isAllowlistedApiRead("/api/measurements-export")).toBe(false);
|
||||
|
||||
@@ -34,4 +34,8 @@ export const cycleKeys = {
|
||||
/** The caller's own custom symptoms (decrypted labels) the log-day sheet
|
||||
* merges into the seeded chip grid. */
|
||||
cycleCustomSymptoms: () => ["cycle", "custom-symptoms"] as const,
|
||||
/** #1350 — the calendar subscription's status. Under the `["cycle"]` prefix
|
||||
* on purpose: a logged period changes what the feed serves (a late period
|
||||
* becomes a forecast again), so every cycle write refreshes the card too. */
|
||||
cycleFeedSubscription: () => ["cycle", "feed-subscription"] as const,
|
||||
};
|
||||
@@ -65,6 +65,8 @@ export function recordActivityVerbLine(
|
||||
if (refused !== null) return refused;
|
||||
const medication = medicationManageVerb(t, action, name);
|
||||
if (medication !== null) return medication;
|
||||
const cycle = cycleManageVerb(t, action, name);
|
||||
if (cycle !== null) return cycle;
|
||||
switch (action) {
|
||||
case "measurement.create":
|
||||
return t("recordSharing.activityVerb.measurementCreate", { name });
|
||||
@@ -270,19 +272,6 @@ export function recordActivityVerbLine(
|
||||
case "mental-health.create":
|
||||
return t("recordSharing.activityVerb.mentalHealthCreate", { name });
|
||||
|
||||
case "cycle.cycle.delete":
|
||||
return t("recordSharing.activityVerb.cycleDelete", { name });
|
||||
case "cycle.day-log.upsert":
|
||||
return t("recordSharing.activityVerb.cycleDayLogUpsert", { name });
|
||||
case "cycle.day-log.update":
|
||||
return t("recordSharing.activityVerb.cycleDayLogUpdate", { name });
|
||||
case "cycle.day-log.delete":
|
||||
return t("recordSharing.activityVerb.cycleDayLogDelete", { name });
|
||||
case "cycle.period.boundary":
|
||||
return t("recordSharing.activityVerb.cyclePeriodBoundary", { name });
|
||||
case "cycle.symptom.custom.create":
|
||||
return t("recordSharing.activityVerb.cycleSymptomCreate", { name });
|
||||
|
||||
case "nutrient.water.write":
|
||||
return t("recordSharing.activityVerb.nutrientWaterWrite", { name });
|
||||
|
||||
@@ -496,3 +485,39 @@ function medicationManageVerb(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The cycle section's delegated acts: its log, its period boundaries, its
|
||||
* symptom vocabulary and its calendar subscription (#1350). Null for anything
|
||||
* else, so the caller falls through to the rest of the list.
|
||||
*/
|
||||
function cycleManageVerb(
|
||||
t: Translator,
|
||||
action: string,
|
||||
name: string,
|
||||
): string | null {
|
||||
switch (action) {
|
||||
case "cycle.cycle.delete":
|
||||
return t("recordSharing.activityVerb.cycleDelete", { name });
|
||||
case "cycle.day-log.upsert":
|
||||
return t("recordSharing.activityVerb.cycleDayLogUpsert", { name });
|
||||
case "cycle.day-log.update":
|
||||
return t("recordSharing.activityVerb.cycleDayLogUpdate", { name });
|
||||
case "cycle.day-log.delete":
|
||||
return t("recordSharing.activityVerb.cycleDayLogDelete", { name });
|
||||
case "cycle.period.boundary":
|
||||
return t("recordSharing.activityVerb.cyclePeriodBoundary", { name });
|
||||
case "cycle.symptom.custom.create":
|
||||
return t("recordSharing.activityVerb.cycleSymptomCreate", { name });
|
||||
case "cycle.feed.create":
|
||||
return t("recordSharing.activityVerb.cycleFeedCreate", { name });
|
||||
case "cycle.feed.update":
|
||||
return t("recordSharing.activityVerb.cycleFeedUpdate", { name });
|
||||
case "cycle.feed.rotate":
|
||||
return t("recordSharing.activityVerb.cycleFeedRotate", { name });
|
||||
case "cycle.feed.revoke":
|
||||
return t("recordSharing.activityVerb.cycleFeedRevoke", { name });
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
*
|
||||
* `hlk_` access tokens, `hlr_` refresh tokens, `hls_` clinician share-link
|
||||
* tokens and session secrets, `hlv_` registration invite tokens, `hlp_`
|
||||
* managed-profile handover tokens (v1.42), `hle_`
|
||||
* managed-profile handover tokens (v1.42), `hlf_` cycle calendar-feed
|
||||
* tokens (#1350), `hle_`
|
||||
* elevation tokens, `hlh_` native sign-in handoff codes, and the MCP OAuth
|
||||
* artifacts `hlac_` (authorization code), `hlrt_` (refresh token) and `hlc_`
|
||||
* (client id);
|
||||
@@ -18,7 +19,7 @@
|
||||
* that knows the secrets in play must also refuse text containing them.
|
||||
*/
|
||||
const SECRET_SHAPED =
|
||||
/(?:\b(?:hlk_|hlr_|hls_|hlv_|hlp_|hle_|hlh_|hlac_|hlrt_|hlc_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/;
|
||||
/(?:\b(?:hlk_|hlr_|hls_|hlv_|hlp_|hlf_|hle_|hlh_|hlac_|hlrt_|hlc_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/;
|
||||
|
||||
export function looksSecretShaped(text: string): boolean {
|
||||
return SECRET_SHAPED.test(text);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* The cycle calendar feed's request contract (#1350).
|
||||
*
|
||||
* Only the options are writable. The token, the record and the carrier are
|
||||
* never body fields: the record comes from the resolved session, the carrier
|
||||
* is the actor, and the token is minted by the server.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/** The feed's title modes. Mirrors `CycleCalendarFeedTitleMode`. */
|
||||
export const cycleFeedTitleModeEnum = z.enum(["DISCREET", "DESCRIPTIVE"]);
|
||||
|
||||
/** Forecast cycles ahead. The feed caps itself further by band width. */
|
||||
export const FEED_HORIZON_MIN = 1;
|
||||
export const FEED_HORIZON_MAX = 6;
|
||||
/** Observed cycles included as history. */
|
||||
export const FEED_HISTORY_MIN = 0;
|
||||
export const FEED_HISTORY_MAX = 12;
|
||||
|
||||
export const cycleFeedOptionsPatchSchema = z
|
||||
.object({
|
||||
horizonCycles: z
|
||||
.number()
|
||||
.int()
|
||||
.min(FEED_HORIZON_MIN)
|
||||
.max(FEED_HORIZON_MAX)
|
||||
.optional(),
|
||||
historyCycles: z
|
||||
.number()
|
||||
.int()
|
||||
.min(FEED_HISTORY_MIN)
|
||||
.max(FEED_HISTORY_MAX)
|
||||
.optional(),
|
||||
includeFertile: z.boolean().optional(),
|
||||
titleMode: cycleFeedTitleModeEnum.optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((v) => Object.keys(v).length > 0, {
|
||||
message: "At least one option is required",
|
||||
});
|
||||
|
||||
export type CycleFeedOptionsPatch = z.infer<typeof cycleFeedOptionsPatchSchema>;
|
||||
+5
-24
@@ -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
|
||||
|
||||
Reference in new issue
Block a user