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:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-06 13:15:22 +02:00
1 parent 31a14ce7bf
commit 3375bbabac
11 files changed
+184 -21

No files matched your search

+31 -5
View File
@@ -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,
};
});
+8 -3
View File
@@ -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" },
+33 -7
View File
@@ -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;
+21
View File
@@ -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 };
}