mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(cycle): second-audit safety, privacy, and write-path hardening
Address the second-round audit (server cluster): - AVOID_PREGNANCY surfaces the fertile window, so it must carry the stronger "not a contraceptive method" disclaimer — wire the existing strong copy via a shared cycleDisclaimerKey(goal), server and client. - Re-anchor the FOLLOWING neighbour cycle on a back-filled period start so an inserted-between-two-cycles start no longer leaves a stale open cycle. - Resolve the sensitive-category encryption flag once per bulk/import batch instead of re-reading the profile per day; hoist the bulk cycle attribution in-memory (no per-entry query). - Drop the raw driver message from the bulk wide-event meta (annotate meta is not run through the central redactor); keep the stable reason + code. - Hard-purge now clears the cycle audit trail and the cycle reminder rows in the push-attempts ledger, so no dated reproductive trace survives. - Discreet mode collapses the Web-Push coalescing tag and drops cycle metadata (phase) from APNs, matching the existing APNs event collapse. - Add the reproductive intent field names to the central redactor denylist. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
31a14ce7bf
commit
3375bbabac
11 files changed
+184
-21
No files matched your search
@@ -4,12 +4,14 @@
|
||||
* Distinct from the per-row soft-delete (`DELETE /api/cycle/day-logs/{id}`,
|
||||
* which leaves a tombstone for the sync feed): this HARD-deletes every
|
||||
* cycle row the user owns — day-logs (and their symptom links, by cascade),
|
||||
* menstrual cycles, predictions, and the reminder ledger — so nothing
|
||||
* reproductive persists in Postgres. The `CycleProfile` row is reset to its
|
||||
* defaults (tracking left enabled) rather than dropped, so the gate and
|
||||
* settings page keep working.
|
||||
* menstrual cycles, predictions, the cycle audit trail, and the cycle
|
||||
* reminder-delivery rows in the push-attempts ledger — so no dated
|
||||
* reproductive trace survives in Postgres (the post-Dobbs threat model). The
|
||||
* `CycleProfile` row is left in place (gate + settings keep working).
|
||||
*
|
||||
* Gated (`cycle.disabled` 403) and owner-scoped. Audited.
|
||||
* Gated (`cycle.disabled` 403) and owner-scoped. The purge action itself IS
|
||||
* audited (written after the transaction, so it survives) — its `details`
|
||||
* carry only counts, no reproductive content.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
@@ -39,10 +41,34 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const cycles = await tx.menstrualCycle.deleteMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
// The cycle audit trail carries dated reproductive-adjacent details
|
||||
// (period boundaries, the AVOID_PREGNANCY goal-nudge, goal changes). A
|
||||
// purge that promises "nothing reproductive persists" must clear it too.
|
||||
// The purge's own audit row is written AFTER this transaction, so it
|
||||
// survives (counts only, no reproductive content).
|
||||
const auditRows = await tx.auditLog.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
OR: [
|
||||
{ action: { startsWith: "cycle." } },
|
||||
{ action: "user.cycle-prefs.update" },
|
||||
],
|
||||
},
|
||||
});
|
||||
// The shared push-attempts ledger holds dated cycle reminder-delivery
|
||||
// rows (eventType + createdAt) for up to 90 days — drop the cycle ones.
|
||||
const pushRows = await tx.pushAttempt.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
eventType: { in: ["CYCLE_PERIOD_SOON", "CYCLE_PERIOD_CONFIRM"] },
|
||||
},
|
||||
});
|
||||
return {
|
||||
dayLogs: dayLogs.count,
|
||||
predictions: predictions.count,
|
||||
cycles: cycles.count,
|
||||
auditRows: auditRows.count,
|
||||
pushRows: pushRows.count,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
* `{ profile, prediction, days }`. The synchronous engine call is cheap
|
||||
* (pure stats); the heavy part is the cache persist, which is debounced
|
||||
* stale-while-revalidate and fire-and-forget so it never blocks the GET.
|
||||
* Fertile-window fields are goal-gated (null unless TRYING_TO_CONCEIVE),
|
||||
* suppressed server-side.
|
||||
* Fertile-window fields are goal-gated (surfaced for TRYING_TO_CONCEIVE and
|
||||
* AVOID_PREGNANCY — the latter with the stronger "not a contraceptive method"
|
||||
* disclaimer; suppressed for GENERAL_HEALTH / PERIMENOPAUSE / OFF),
|
||||
* resolved server-side.
|
||||
*
|
||||
* Default range: current cycle − 90d … +180d forward.
|
||||
*/
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
import {
|
||||
toCyclePredictionDTO,
|
||||
goalAllowsFertileWindow,
|
||||
cycleDisclaimerKey,
|
||||
} from "@/lib/cycle/dto";
|
||||
import { persistPredictionCache } from "@/lib/cycle/prediction-cache";
|
||||
import { addDays, dayDiff } from "@/lib/cycle/day-math";
|
||||
@@ -158,7 +161,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
userLocale: user.locale ?? undefined,
|
||||
});
|
||||
const t = getServerTranslator(locale);
|
||||
const disclaimer = t.t("cycle.prediction.disclaimer");
|
||||
// AVOID_PREGNANCY surfaces the fertile window, so it must carry the stronger
|
||||
// "not a contraceptive method" caveat (QA H-1).
|
||||
const disclaimer = t.t(cycleDisclaimerKey(profile.goal));
|
||||
|
||||
annotate({
|
||||
action: { name: "cycle.calendar.read" },
|
||||
|
||||
@@ -24,12 +24,12 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { cycleBulkSchema, MAX_CYCLE_BULK_ENTRIES } from "@/lib/validations/cycle";
|
||||
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
|
||||
import { findOwningCycleId } from "@/lib/cycle/cycle-attribution";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
|
||||
const BATCH_RATE_LIMIT_MAX = 60;
|
||||
@@ -92,6 +92,25 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
const { entries } = parsed.data;
|
||||
const tz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
// Hoist cycle attribution + the invariant encryption flag out of the loop:
|
||||
// load the user's cycle start dates once and resolve the owning span in
|
||||
// memory (latest start <= date), instead of a findOwningCycleId query per
|
||||
// entry (up to 500) + a profile read per entry (QA M-3 / round-1 N+1).
|
||||
const cycleRows = await prisma.menstrualCycle.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { startDate: "asc" },
|
||||
select: { id: true, startDate: true },
|
||||
});
|
||||
const owningCycleId = (date: string): string | null => {
|
||||
let id: string | null = null;
|
||||
for (const c of cycleRows) {
|
||||
if (c.startDate <= date) id = c.id;
|
||||
else break;
|
||||
}
|
||||
return id;
|
||||
};
|
||||
const encryptSensitive = gate.profile.sensitiveCategoryEncryption;
|
||||
|
||||
const results: EntryResult[] = [];
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
@@ -101,8 +120,14 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
try {
|
||||
const cycleId = await findOwningCycleId(user.id, entry.date);
|
||||
const r = await upsertCycleDayLog(user.id, entry, tz, cycleId);
|
||||
const cycleId = owningCycleId(entry.date);
|
||||
const r = await upsertCycleDayLog(
|
||||
user.id,
|
||||
entry,
|
||||
tz,
|
||||
cycleId,
|
||||
encryptSensitive,
|
||||
);
|
||||
|
||||
let status: EntryStatus;
|
||||
if (!r.existed) {
|
||||
@@ -124,9 +149,10 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
// Map to a stable closed set of reason codes — never echo the raw
|
||||
// driver message (it can carry column / constraint / value fragments
|
||||
// and bypasses the central redaction boundary). The full message is
|
||||
// logged through the WideEvent path where redaction applies.
|
||||
// driver message (it can carry column / constraint / value fragments).
|
||||
// `annotate` meta is NOT run through the central redactor (only
|
||||
// setError/setHttp are), so the raw message must not be attached here:
|
||||
// record the stable code + the Prisma error code only (QA M-sec1).
|
||||
const code =
|
||||
typeof err === "object" && err !== null && "code" in err
|
||||
? (err as { code?: unknown }).code
|
||||
@@ -142,7 +168,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
meta: {
|
||||
index: i,
|
||||
reason,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
error_code: typeof code === "string" ? code : "unknown",
|
||||
},
|
||||
});
|
||||
skipped += 1;
|
||||
|
||||
@@ -95,6 +95,27 @@ async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
syncVersion: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
// Re-anchor the FOLLOWING neighbour: when this start is back-filled
|
||||
// between two existing cycles, the new cycle's end + length are already
|
||||
// known from its successor's start. Without this the inserted cycle keeps
|
||||
// endDate/lengthDays = null even though the next start exists, and the
|
||||
// iOS mirror + history surfaces read a stale open cycle (QA M-1).
|
||||
const next = await db.menstrualCycle.findFirst({
|
||||
where: { userId: user.id, deletedAt: null, startDate: { gt: date } },
|
||||
orderBy: { startDate: "asc" },
|
||||
select: { startDate: true },
|
||||
});
|
||||
if (next) {
|
||||
await db.menstrualCycle.update({
|
||||
where: { id: cycle.id },
|
||||
data: {
|
||||
endDate: addDays(next.startDate, -1),
|
||||
lengthDays: dayDiff(next.startDate, date),
|
||||
syncVersion: { increment: 1 },
|
||||
},
|
||||
});
|
||||
}
|
||||
return { cycleId: cycle.id as string | null };
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,18 @@ export function CycleView() {
|
||||
const loading = calendar.isLoading && !calendar.data;
|
||||
const calendarError = calendar.isError && !calendar.data;
|
||||
const tileHue = wheel.phase ? PHASE_HUE[wheel.phase] : undefined;
|
||||
const disclaimerText = t("cycle.prediction.disclaimer");
|
||||
// AVOID_PREGNANCY surfaces the fertile window, so it must show the stronger
|
||||
// "not a contraceptive method" caveat. Prefer the server-resolved
|
||||
// prediction.disclaimer (already goal-correct); fall back to the goal-derived
|
||||
// key for the no-prediction calendar tab (QA H-1).
|
||||
const goal = calendar.data?.profile.goal;
|
||||
const disclaimerText =
|
||||
calendar.data?.prediction?.disclaimer ??
|
||||
t(
|
||||
goal === "AVOID_PREGNANCY"
|
||||
? "cycle.disclaimer"
|
||||
: "cycle.prediction.disclaimer",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -173,18 +173,27 @@ const EXISTING_SELECT = {
|
||||
/**
|
||||
* Upsert one cycle day-log. `tz` anchors the date string. When
|
||||
* `cycleId` is supplied the row is attributed to that cycle span.
|
||||
*
|
||||
* `encryptSensitiveFlag` lets a batch caller (bulk drain / Apple-Health import
|
||||
* flush) resolve the invariant `sensitiveCategoryEncryption` flag ONCE and
|
||||
* thread it in, instead of this helper re-reading the profile per row (a
|
||||
* per-day round-trip on a multi-year import — QA M-3). The single POST/PATCH
|
||||
* paths omit it and keep the lazy read.
|
||||
*/
|
||||
export async function upsertCycleDayLog(
|
||||
userId: string,
|
||||
entry: CycleDayLogInput,
|
||||
tz: string | null,
|
||||
cycleId: string | null = null,
|
||||
encryptSensitiveFlag?: boolean,
|
||||
): Promise<DayLogWriteResult> {
|
||||
const source = entry.source;
|
||||
|
||||
// Resolve the sensitive-category encryption flag once per write.
|
||||
const profile = await getOrCreateCycleProfile(userId);
|
||||
const encryptSensitive = profile.sensitiveCategoryEncryption;
|
||||
// Resolve the sensitive-category encryption flag once per write (or reuse the
|
||||
// flag the batch caller already resolved).
|
||||
const encryptSensitive =
|
||||
encryptSensitiveFlag ??
|
||||
(await getOrCreateCycleProfile(userId)).sensitiveCategoryEncryption;
|
||||
|
||||
const where: Prisma.CycleDayLogWhereUniqueInput = entry.externalId
|
||||
? {
|
||||
|
||||
@@ -247,3 +247,17 @@ export function goalAllowsFertileWindow(goal: string): boolean {
|
||||
// hidden for GENERAL_HEALTH / PERIMENOPAUSE / OFF.
|
||||
return goal === "TRYING_TO_CONCEIVE" || goal === "AVOID_PREGNANCY";
|
||||
}
|
||||
|
||||
/**
|
||||
* The i18n key for the disclaimer a goal must show. AVOID_PREGNANCY surfaces the
|
||||
* fertile window, so it MUST carry the stronger "not a contraceptive method —
|
||||
* never safe to assume unprotected sex" copy (`cycle.disclaimer`); every other
|
||||
* goal gets the standard "estimates, not medical advice" line
|
||||
* (`cycle.prediction.disclaimer`). Shared by the server route and the client so
|
||||
* web/iOS/engine show the same caveat. (algorithm.md §4 — safety-relevant.)
|
||||
*/
|
||||
export function cycleDisclaimerKey(goal: string): string {
|
||||
return goal === "AVOID_PREGNANCY"
|
||||
? "cycle.disclaimer"
|
||||
: "cycle.prediction.disclaimer";
|
||||
}
|
||||
@@ -208,6 +208,15 @@ export class CycleImportAccumulator {
|
||||
return id;
|
||||
};
|
||||
|
||||
// Resolve the invariant encryption flag once for the whole flush, instead
|
||||
// of re-reading the profile per upserted day (QA M-3). Default ON if the
|
||||
// profile somehow vanished between the gate check and here.
|
||||
const encProfile = await prisma.cycleProfile.findUnique({
|
||||
where: { userId: this.userId },
|
||||
select: { sensitiveCategoryEncryption: true },
|
||||
});
|
||||
const encryptSensitive = encProfile?.sensitiveCategoryEncryption ?? true;
|
||||
|
||||
for (const [dayKey, bucket] of this.byDay.entries()) {
|
||||
const symptoms =
|
||||
bucket.symptomKeys.size > 0
|
||||
@@ -232,6 +241,7 @@ export class CycleImportAccumulator {
|
||||
entry,
|
||||
this.userTimezone,
|
||||
cycleId,
|
||||
encryptSensitive,
|
||||
);
|
||||
stats.daysUpserted += 1;
|
||||
if (!result.existed) stats.daysInserted += 1;
|
||||
|
||||
@@ -618,7 +618,14 @@ export async function sendViaApns(
|
||||
alert: { title: payload.title, body: stripHtml(payload.message) },
|
||||
data: {
|
||||
eventType: routingEvent,
|
||||
...pickIosMetadata(payload.metadata),
|
||||
// In discreet mode also drop cycle-semantic metadata (e.g. `phase`)
|
||||
// so a future cycle push can't name a cycle concept on the wire even
|
||||
// though the allowlist permits `phase` (QA LOW).
|
||||
...pickIosMetadata(
|
||||
payload.discreet
|
||||
? stripCycleMetadata(payload.metadata)
|
||||
: payload.metadata,
|
||||
),
|
||||
},
|
||||
threadId: routingEvent,
|
||||
// The iOS app registers a `UNNotificationCategory` per event-type
|
||||
@@ -769,3 +776,21 @@ function pickIosMetadata(
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Cycle-semantic metadata keys that must never ride a discreet payload. */
|
||||
const CYCLE_METADATA_KEYS = new Set(["phase"]);
|
||||
|
||||
/**
|
||||
* Drop cycle-semantic metadata (e.g. `phase`) so a discreet cycle push can't
|
||||
* name a cycle concept on the wire even though the allowlist permits the key.
|
||||
*/
|
||||
function stripCycleMetadata(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!metadata) return metadata;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(metadata)) {
|
||||
if (!CYCLE_METADATA_KEYS.has(key)) out[key] = metadata[key];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -79,7 +79,11 @@ export async function sendViaWebPush(
|
||||
const pushPayload = JSON.stringify({
|
||||
title: payload.title,
|
||||
body: payload.message.replace(/<[^>]*>/g, ""),
|
||||
tag: payload.eventType,
|
||||
// Discreet mode (cycle privacy): the coalescing `tag` must not name the
|
||||
// event. Web-Push payloads are VAPID-encrypted (only the SW sees this),
|
||||
// but the discreet contract still requires no event name on the wire —
|
||||
// mirror the APNs GENERIC_EVENT collapse (QA M-sec3).
|
||||
tag: payload.discreet ? "REMINDER" : payload.eventType,
|
||||
url: "/",
|
||||
});
|
||||
|
||||
|
||||
@@ -53,6 +53,18 @@ export const SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [
|
||||
/insurance/i,
|
||||
/insurer/i,
|
||||
/kvnr/i,
|
||||
// v1.15.0 — reproductive-health intent fields (the post-Dobbs threat
|
||||
// model's highest-sensitivity category). These are encrypted at rest in the
|
||||
// sensitive-category envelope and no cycle route currently routes its body
|
||||
// through `buildPayloadDiagnostic`, so this is defence-in-depth: if a future
|
||||
// change ever excerpts a cycle request body, the intent values redact rather
|
||||
// than land verbatim. (`flow`/`note` are too generic to add safely.)
|
||||
/sexualactivity/i,
|
||||
/protectedsex/i,
|
||||
/pregnancytest/i,
|
||||
/progesteronetest/i,
|
||||
/contraceptive/i,
|
||||
/cervicalmucus/i,
|
||||
];
|
||||
|
||||
const REDACTED = "[redacted]";
|
||||
|
||||
Reference in new issue
Block a user