fix: recover managed sharing boundaries

This commit is contained in:
Marc-André Bombeck committed 2026-08-06 00:19:35 +02:00
1 parent 01562b4aef
commit d86358f3e4
96 files changed
+3578 -397

No files matched your search

@@ -196,8 +196,11 @@ describe("POST /api/cycle/period — external-id stability floor", () => {
});
it("accepts the real HealthKit id and reaches the write transaction", async () => {
// v1.37.0 — the boundary transaction reports the anchors it moved so the
// audit row can name them (C4); an empty set is the no-neighbour case.
vi.mocked(prisma.$transaction).mockResolvedValue({
cycleId: "cycle-1",
moved: {},
} as never);
const res = await postPeriod(
req("/api/cycle/period", {
+5 -2
View File
@@ -6,7 +6,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
@@ -16,7 +16,10 @@ type RouteParams = { params: Promise<{ id: string }> };
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Soft, audited, and it emits a sync tombstone, so the
// cycle is reconstructable from its own row. The whole-domain eraser
// (`DELETE /api/cycle/all`) stays the owner's at every level.
const { user } = await requireRecordAuth("manage", "cycle");
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
+24 -4
View File
@@ -11,9 +11,10 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { overwriteDetails } from "@/lib/sharing/audit-details";
import {
apiSuccess,
apiError,
@@ -32,7 +33,8 @@ type RouteParams = { params: Promise<{ id: string }> };
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Editing one day of the cycle log.
const { user } = await requireRecordAuth("manage", "cycle");
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
@@ -44,6 +46,8 @@ export const PATCH = apiHandler(
select: {
id: true,
userId: true,
// v1.37.0 — the day the audit row names (C4).
date: true,
sexualActivity: true,
protectedSex: true,
pregnancyTest: true,
@@ -156,7 +160,21 @@ export const PATCH = apiHandler(
await auditLog("cycle.day-log.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { dayLogId: id },
// C4 in this domain's shape: the date and the fields replaced, named
// and never valued. See the sibling create route for why the values
// stay off the audit table in this domain.
details: {
dayLogId: id,
date: existing.date,
...overwriteDetails({
before: {},
after: {},
redacted: Object.entries(body)
.filter(([, value]) => value !== undefined)
.map(([key]) => key)
.sort(),
}),
},
});
annotate({
@@ -173,7 +191,9 @@ export const PATCH = apiHandler(
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Soft delete: the row stays and the sync feed carries
// it as a tombstone.
const { user } = await requireRecordAuth("manage", "cycle");
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
+34 -3
View File
@@ -14,9 +14,10 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { overwriteDetails } from "@/lib/sharing/audit-details";
import {
apiSuccess,
apiError,
@@ -75,8 +76,24 @@ export const GET = apiHandler(async (request: NextRequest) => {
return apiSuccess(row ? toCycleDayLogDTO(row) : null);
});
/**
* The day-log fields a write actually carried, for the audit row. Names only:
* every one of these is either intimate or encrypted at rest, and the audit
* table is not a second store for either.
*/
function replacedDayLogFields(entry: Record<string, unknown>): string[] {
const carried = ["date", "source", "externalId", "loggedAt"];
return Object.entries(entry)
.filter(([key, value]) => value !== undefined && !carried.includes(key))
.map(([key]) => key)
.sort();
}
async function postDayLog(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. The upsert replaces the day, which is what edit means
// here; the gate below resolves `user.gender` against the RECORD, so the
// module follows the record rather than the caller.
const { user } = await requireRecordAuth("manage", "cycle");
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
@@ -139,7 +156,21 @@ async function postDayLog(request: NextRequest): Promise<Response> {
await auditLog("cycle.day-log.upsert", {
userId: user.id,
ipAddress: getClientIp(request),
details: { dayLogId: result.id, existed: result.existed },
// C4 in this domain's shape: the date and the fields the write replaced,
// named and never valued. This is the product's most sensitive domain and
// §7.5's "no full-row snapshots" is at its sharpest here — a feed line
// saying which day was rewritten is the whole requirement, and the values
// behind it stay on the row where they are already encrypted.
details: {
dayLogId: result.id,
existed: result.existed,
date: entry.date,
...overwriteDetails({
before: {},
after: {},
redacted: result.existed ? replacedDayLogFields(entry) : [],
}),
},
});
annotate({
+77 -8
View File
@@ -12,9 +12,10 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { overwriteDetails } from "@/lib/sharing/audit-details";
import {
apiSuccess,
getClientIp,
@@ -40,8 +41,28 @@ import { addDays, dayDiff } from "@/lib/cycle/day-math";
export const POST = apiHandler(withIdempotency<[NextRequest]>(postPeriod));
/**
* v1.37.0 — the anchors a boundary write moves, carried out of the
* transaction so the audit row can name them (C4).
*/
interface MovedAnchors {
priorCycleId?: string;
/** Day keys, the shape this domain stores its dates in. */
priorEndDateBefore?: string | null;
priorEndDateAfter?: string | null;
priorLengthBefore?: number | null;
priorLengthAfter?: number | null;
openedEndDateBefore?: string | null;
openedEndDateAfter?: string | null;
periodEndDateBefore?: string | null;
periodEndDateAfter?: string | null;
}
async function postPeriod(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Setting a boundary re-anchors the neighbouring cycles,
// which is the destructive part of it: a handful of dates move and nothing
// else records what they were. The audit row below carries them (C4).
const { user } = await requireRecordAuth("manage", "cycle");
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
@@ -81,9 +102,23 @@ async function postPeriod(request: NextRequest): Promise<Response> {
const prior = await db.menstrualCycle.findFirst({
where: { userId: user.id, deletedAt: null, startDate: { lt: date } },
orderBy: { startDate: "desc" },
select: { id: true, startDate: true },
// v1.37.0 — `endDate` and `lengthDays` are read for the audit row:
// this write overwrites both on the prior cycle and they are the
// dates C4 exists to name.
select: {
id: true,
startDate: true,
endDate: true,
lengthDays: true,
},
});
const moved: MovedAnchors = {};
if (prior) {
moved.priorCycleId = prior.id;
moved.priorEndDateBefore = prior.endDate;
moved.priorEndDateAfter = addDays(date, -1);
moved.priorLengthBefore = prior.lengthDays;
moved.priorLengthAfter = dayDiff(date, prior.startDate);
await db.menstrualCycle.update({
where: { id: prior.id },
data: {
@@ -117,6 +152,8 @@ async function postPeriod(request: NextRequest): Promise<Response> {
select: { startDate: true },
});
if (next) {
moved.openedEndDateBefore = cycle.endDate;
moved.openedEndDateAfter = addDays(next.startDate, -1);
await db.menstrualCycle.update({
where: { id: cycle.id },
data: {
@@ -126,23 +163,30 @@ async function postPeriod(request: NextRequest): Promise<Response> {
},
});
}
return { cycleId: cycle.id as string | null };
return { cycleId: cycle.id as string | null, moved };
}
// `end`: stamp the current cycle's periodEndDate.
const current = await db.menstrualCycle.findFirst({
where: { userId: user.id, deletedAt: null, startDate: { lte: date } },
orderBy: { startDate: "desc" },
select: { id: true },
// v1.37.0 — `periodEndDate` for the audit row: this arm overwrites it.
select: { id: true, periodEndDate: true },
});
if (!current) {
return { cycleId: null };
return { cycleId: null, moved: {} satisfies MovedAnchors };
}
await db.menstrualCycle.update({
where: { id: current.id },
data: { periodEndDate: date, syncVersion: { increment: 1 } },
});
return { cycleId: current.id as string | null };
return {
cycleId: current.id as string | null,
moved: {
periodEndDateBefore: current.periodEndDate,
periodEndDateAfter: date,
} satisfies MovedAnchors,
};
});
if (txResult.cycleId === null) {
@@ -188,7 +232,32 @@ async function postPeriod(request: NextRequest): Promise<Response> {
await auditLog("cycle.period.boundary", {
userId: user.id,
ipAddress: getClientIp(request),
details: { action, date, cycleId },
// C4 — the dates this boundary moved on the neighbouring cycles. They are
// the only unrecoverable part of the write: the day log it upserts is a
// row, the re-anchoring is an overwrite of two columns on somebody else's
// cycle history.
details: {
action,
date,
cycleId,
...(txResult.moved.priorCycleId
? { priorCycleId: txResult.moved.priorCycleId }
: {}),
...overwriteDetails({
before: {
priorEndDate: txResult.moved.priorEndDateBefore,
priorLengthDays: txResult.moved.priorLengthBefore,
openedEndDate: txResult.moved.openedEndDateBefore,
periodEndDate: txResult.moved.periodEndDateBefore,
},
after: {
priorEndDate: txResult.moved.priorEndDateAfter,
priorLengthDays: txResult.moved.priorLengthAfter,
openedEndDate: txResult.moved.openedEndDateAfter,
periodEndDate: txResult.moved.periodEndDateAfter,
},
}),
},
});
annotate({
+9 -3
View File
@@ -24,7 +24,7 @@ import {
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -68,15 +68,21 @@ export const GET = apiHandler(async () => {
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. The record's own symptom vocabulary, which the day-log
// writes the level admits need in order to say anything.
const { user, actor } = await requireRecordAuth("manage", "cycle");
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
// Cap mutation rate so a single session can't flood the encrypted-label
// catalogue (each create runs an encrypt + audit write).
// v1.37.0 — C1: the bucket keys on the ACTOR, the frozen precedent from
// `medications/compliance`. A manager burns their own allowance rather than
// locking the owner out of their own record, and cannot collect a fresh one
// by switching records.
const rl = await checkRateLimit(
`cycle:symptom:custom:${user.id}`,
`cycle:symptom:custom:${actor.id}`,
30,
60_000,
);