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

+83 -7
View File
@@ -12,9 +12,13 @@
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 {
destroyedDetails,
overwriteDetails,
} from "@/lib/sharing/audit-details";
import {
apiSuccess,
apiError,
@@ -49,12 +53,26 @@ export const GET = apiHandler(
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Correcting an allergy entry, with the replaced fields
// on the audit row.
const { user } = await requireRecordAuth("manage", "profile");
const { id } = await params;
// The pre-image the audit row needs (C4). Encrypted columns stay out of
// the select: the feed names them and never quotes them.
const existing = await prisma.allergy.findUnique({
where: { id },
select: { id: true, userId: true, deletedAt: true },
select: {
id: true,
userId: true,
deletedAt: true,
substance: true,
category: true,
type: true,
severity: true,
status: true,
onsetAt: true,
},
});
if (
!existing ||
@@ -101,7 +119,34 @@ export const PATCH = apiHandler(
await auditLog("allergy.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { allergyId: id },
// C4 — the replaced fields. The reaction and the note are named and
// never quoted; both are encrypted at rest and the audit table is not a
// second store for them.
details: {
allergyId: id,
...overwriteDetails({
before: {
substance: existing.substance,
category: existing.category,
type: existing.type,
severity: existing.severity,
status: existing.status,
onsetAt: existing.onsetAt,
},
after: {
substance: updated.substance,
category: updated.category,
type: updated.type,
severity: updated.severity,
status: updated.status,
onsetAt: updated.onsetAt,
},
redacted: [
...(entry.reaction !== undefined ? ["reaction"] : []),
...(entry.note !== undefined ? ["note"] : []),
],
}),
},
});
annotate({
@@ -114,12 +159,27 @@ export const PATCH = apiHandler(
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE, and a hard delete: the row goes and the audit row is
// what is left. C3 below is the condition this admission rests on.
const { user } = await requireRecordAuth("manage", "profile");
const { id } = await params;
// Widened for the audit row (C3): what was destroyed, in plaintext columns
// only. `reactionEncrypted` and `notesEncrypted` are deliberately absent —
// lifting a decrypted reaction into the audit table would give it a second
// retention story and no rotation.
const existing = await prisma.allergy.findUnique({
where: { id },
select: { id: true, userId: true },
select: {
id: true,
userId: true,
substance: true,
category: true,
type: true,
severity: true,
status: true,
onsetAt: true,
},
});
if (!existing || existing.userId !== user.id) {
return apiError("Allergy not found", 404);
@@ -136,7 +196,23 @@ export const DELETE = apiHandler(
await auditLog("allergy.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { allergyId: id },
// C3 — the allergen, its severity and its recorded date, and never the
// encrypted reaction text.
details: {
allergyId: id,
...destroyedDetails({
model: "Allergy",
id,
label: existing.substance,
effectiveAt: existing.onsetAt,
extra: {
category: existing.category,
type: existing.type,
severity: existing.severity,
status: existing.status,
},
}),
},
});
annotate({
+6 -3
View File
@@ -11,7 +11,7 @@
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 {
@@ -91,13 +91,16 @@ async function postAllergy(request: NextRequest): Promise<Response> {
// and family history have exactly one home today, that home is a personal
// account surface a switch rightly closes, and bolting a second copy onto a
// shared page would split one concept across two places. Re-admitting is
// one line here plus one entry in `delegable-surface-guard.test.ts` plus the
// one line here plus one entry in `sharing-surface-guard.test.ts` plus the
// paragraph that argues it — which is exactly the reviewed diff that guard
// exists to force.
//
// The half that was always the point is untouched: a caregiver can still
// READ the allergy list inside the record.
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Recording an allergy for somebody whose record you
// manage is the case the level exists for; on a managed profile it is the
// guardian's ordinary act. Additive, audited, and the row is the record's.
const { user } = await requireRecordAuth("manage", "profile");
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
@@ -38,6 +38,7 @@ vi.mock("next/headers", () => ({
import { PUT, DELETE } from "../route";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
const SESSION_OK = {
@@ -78,6 +79,9 @@ function delReq(): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
// v1.37.0 — the 422 breadcrumb goes through `auditLog()` and the route
// chains `.catch()` on it; `resetAllMocks` clears the factory's resolution.
vi.mocked(auditLog).mockResolvedValue(undefined as never);
vi.mocked(prisma.$transaction).mockImplementation((async (
ops: Promise<unknown>[],
) => Promise.all(ops)) as never);
+43 -13
View File
@@ -10,6 +10,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { overwriteDetails } from "@/lib/sharing/audit-details";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import {
@@ -83,7 +84,12 @@ export const GET = apiHandler(
export const PUT = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE, and the closest call in this domain: a reference range
// recolours every reading under it, so this is the one admitted edit whose
// error is silent rather than visible. C4 on the audit row is what puts it
// in the owner's feed. The DELETE below stays refused — it takes the
// readings with it and files an id.
const { user } = await requireRecordAuth("manage", "labs");
const { id } = await params;
const existing = await prisma.biomarker.findFirst({ where: { id } });
@@ -107,17 +113,16 @@ export const PUT = apiHandler(
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "labs.biomarker.update.validation-failed",
details: JSON.stringify({ issues: auditIssues, biomarkerId: id }),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a manager's malformed payload would read as the owner's own.
void auditLog("labs.biomarker.update.validation-failed", {
userId: user.id,
details: { issues: auditIssues, biomarkerId: id },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "labs.biomarker.update.invalid",
});
@@ -175,7 +180,32 @@ export const PUT = apiHandler(
await auditLog("biomarker.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { biomarkerId: id },
// C4, and this is the edit that needs it most: a reference range
// recolours every reading under it, so the error is silent. The feed has
// to show the range that was there before. The context note is named and
// never quoted; it is encrypted on the row.
details: {
biomarkerId: id,
...overwriteDetails({
before: {
name: existing.name,
unit: existing.unit,
lowerBound: existing.lowerBound,
upperBound: existing.upperBound,
panel: existing.panel,
hidden: existing.hidden,
},
after: {
name: updated.name,
unit: updated.unit,
lowerBound: updated.lowerBound,
upperBound: updated.upperBound,
panel: updated.panel,
hidden: updated.hidden,
},
redacted: d.context !== undefined ? ["context"] : [],
}),
},
});
annotate({
@@ -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,
);
+4 -2
View File
@@ -51,7 +51,7 @@
* map the shared snapshot builder uses (`disabledSummaryTypes`); the filter
* runs OUTSIDE the cache because a module toggle does not evict it.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
@@ -347,7 +347,9 @@ interface ActivityDayRow {
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
annotate({ action: { name: "dashboard.summary" } });
// v1.4.25 W7b — anchor every day-bucket call to the user's display
+12 -5
View File
@@ -7,8 +7,9 @@
* - `fhir` → `application/fhir+json` (HL7 FHIR R4 document Bundle)
* - `package` → `application/zip` (PDF + Bundle + README)
*
* Auth: cookie session OR Bearer token (`requireAuth`).
* Rate-limit: shared `export:<userId>` bucket (10/h).
* Auth: cookie session OR Bearer token, resolved through `requireRecordAuth`
* at MANAGE — the report is reachable on a record the caller manages.
* Rate-limit: shared `export:<actorId>` bucket (10/h).
* Audit: `health-record.export` — format, window, and the chosen leaf ids,
* never the values behind them.
*
@@ -21,7 +22,7 @@
*/
import { NextRequest, NextResponse } from "next/server";
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 {
@@ -84,7 +85,10 @@ async function rememberChoices(
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE: the doctor report is the one export an invited manager
// reaches, because it renders a prepared artefact from a declared selection
// rather than handing over the record whole.
const { user, actor } = await requireRecordAuth("manage", "record");
annotate({ action: { name: "export.health-record.build" } });
// The whole doctor-report surface is the `doctorReport` module. Refuse with
@@ -93,7 +97,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
const gate = await requireModuleEnabled(user.id, "doctorReport");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
// v1.37.0 — the shared export bucket keys on the ACTOR. A manager burns
// their own hourly allowance rather than the owner's, and switching records
// does not hand them a fresh one.
const rl = await checkRateLimit(`export:${actor.id}`, 10, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximum 10 exports per hour", 429);
}
+32 -5
View File
@@ -10,9 +10,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,
@@ -51,12 +52,21 @@ export const GET = apiHandler(
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Correcting one relative's entry.
const { user } = await requireRecordAuth("manage", "profile");
const { id } = await params;
// Widened for the audit row (C4); the encrypted note stays out.
const existing = await prisma.familyHistoryEntry.findUnique({
where: { id },
select: { id: true, userId: true, deletedAt: true },
select: {
id: true,
userId: true,
deletedAt: true,
relationship: true,
condition: true,
ageAtOnset: true,
},
});
if (
!existing ||
@@ -98,7 +108,23 @@ export const PATCH = apiHandler(
await auditLog("family-history.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { entryId: id },
// C4 — the replaced fields; the note is named and never quoted.
details: {
entryId: id,
...overwriteDetails({
before: {
relationship: existing.relationship,
condition: existing.condition,
ageAtOnset: existing.ageAtOnset,
},
after: {
relationship: updated.relationship,
condition: updated.condition,
ageAtOnset: updated.ageAtOnset,
},
redacted: entry.note !== undefined ? ["note"] : [],
}),
},
});
annotate({
@@ -115,7 +141,8 @@ 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 reconstructs itself.
const { user } = await requireRecordAuth("manage", "profile");
const { id } = await params;
const existing = await prisma.familyHistoryEntry.findUnique({
+4 -2
View File
@@ -12,7 +12,7 @@
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 {
@@ -77,7 +77,9 @@ async function postFamilyHistory(request: NextRequest): Promise<Response> {
// record. The classification admitted it and named the discomfort. Landing
// it together with the surface that offers it means the copy on that surface
// can say whose statement the row is, which no route comment can.
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Third-party data by design, admitted on the same
// reasoning the read arm was: it is the record's own health background.
const { user } = await requireRecordAuth("manage", "profile");
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
@@ -16,7 +16,7 @@
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 {
@@ -138,7 +138,11 @@ export const GET = apiHandler(
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE, and this one is an edit wearing a create's verb: the
// upsert revives a tombstoned day and replaces that day's symptom links.
// Admitted at the level that admits editing, on the condition that the
// audit row says what the write replaced.
const { user } = await requireRecordAuth("manage", "illness");
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
@@ -169,7 +173,18 @@ export const POST = apiHandler(
await auditLog("illness.day-log.upsert", {
userId: user.id,
ipAddress: getClientIp(request),
details: { dayLogId: result.id, episodeId: id, existed: result.existed },
// C3 in this domain's shape: the date, the symptom set that was there
// before, and whether the write brought back a day the owner deleted.
// Without those three the feed says "updated a day log" about a day
// somebody deliberately removed.
details: {
dayLogId: result.id,
episodeId: id,
existed: result.existed,
date: parsed.data.date,
previousSymptoms: result.replaced.previousSymptomKeys,
revived: result.replaced.revived,
},
});
annotate({
@@ -10,7 +10,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 {
@@ -28,7 +28,9 @@ type RouteParams = { params: Promise<{ id: string }> };
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Closing an episode; the window it writes is the
// record's own and the reopen is one route over.
const { user } = await requireRecordAuth("manage", "illness");
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
@@ -13,7 +13,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 { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
@@ -25,7 +25,9 @@ type RouteParams = { params: Promise<{ id: string }> };
export const POST = apiHandler(
withIdempotency<[NextRequest, RouteParams]>(async (request, { params }) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Reopening a deleted episode with its day logs
// intact; the undo beside a delete the level admits.
const { user } = await requireRecordAuth("manage", "illness");
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
+37 -4
View File
@@ -10,9 +10,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,
@@ -55,7 +56,10 @@ export const GET = apiHandler(
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. The module gate below resolves against the RECORD, so
// a manager cannot edit an episode in a record whose owner switched the
// module off.
const { user } = await requireRecordAuth("manage", "illness");
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
@@ -69,6 +73,11 @@ export const PATCH = apiHandler(
deletedAt: true,
onsetAt: true,
resolvedAt: true,
// v1.37.0 — the pre-image the audit row carries (C4).
label: true,
type: true,
lifecycle: true,
parentConditionId: true,
},
});
if (
@@ -150,7 +159,29 @@ export const PATCH = apiHandler(
await auditLog("illness.episode.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { episodeId: id },
// C4 — the replaced fields; the note is named and never quoted.
details: {
episodeId: id,
...overwriteDetails({
before: {
label: existing.label,
type: existing.type,
lifecycle: existing.lifecycle,
onsetAt: existing.onsetAt,
resolvedAt: existing.resolvedAt,
parentConditionId: existing.parentConditionId,
},
after: {
label: updated.label,
type: updated.type,
lifecycle: updated.lifecycle,
onsetAt: updated.onsetAt,
resolvedAt: updated.resolvedAt,
parentConditionId: updated.parentConditionId,
},
redacted: entry.note !== undefined ? ["note"] : [],
}),
},
});
annotate({
@@ -167,7 +198,9 @@ export const PATCH = apiHandler(
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Soft delete with a restore route beside it; the day
// logs under the episode come back with it.
const { user } = await requireRecordAuth("manage", "illness");
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
@@ -16,7 +16,7 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
@@ -31,7 +31,10 @@ const querySchema = z.object({
});
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
@@ -4,7 +4,7 @@ import {
generateBloodPressureStatusForUser,
resolveBloodPressureStatusLocale,
} from "@/lib/insights/blood-pressure-status";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
@@ -13,7 +13,10 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
+5 -2
View File
@@ -4,7 +4,7 @@ import {
generateBmiStatusForUser,
resolveBmiStatusLocale,
} from "@/lib/insights/bmi-status";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
@@ -13,7 +13,10 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
@@ -15,7 +15,7 @@
* session.
*/
import { apiError, apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -27,7 +27,9 @@ export const dynamic = "force-dynamic";
const WINDOW_DAYS = 30;
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
+4 -2
View File
@@ -6,7 +6,7 @@
* to the iOS Insight model (id, title, summary, severity, recommendations,
* provider).
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { apiSuccess } from "@/lib/api-response";
@@ -65,7 +65,9 @@ interface InsightCard {
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
// v1.4.31 — the iOS cards adapter feeds the same per-metric
+4 -2
View File
@@ -16,7 +16,7 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -33,7 +33,9 @@ const coachReadQuerySchema = z.object({
});
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
+8 -2
View File
@@ -22,7 +22,11 @@ import {
} from "@/lib/analytics/compliance";
import { getMedicationCategories } from "@/lib/medication-category";
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler";
import {
apiHandler,
requireRecordAuth,
type AuthContext,
} from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
@@ -41,7 +45,9 @@ import { userDayKey } from "@/lib/tz/format";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
// v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
const rl = await checkAnalyticsReadRateLimit(user.id);
+4 -2
View File
@@ -15,7 +15,7 @@
* lives in `src/lib/insights/correlation-discovery.ts`; this route only
* fetches + day-keys + responds. No LLM, no narrative, no cache table.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
@@ -70,7 +70,9 @@ function tzDayKey(at: Date, tz: string): string {
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
// v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
const rl = await checkAnalyticsReadRateLimit(user.id);
+11 -4
View File
@@ -24,7 +24,7 @@ import { NextRequest } from "next/server";
import { z } from "zod/v4";
import pLimit from "p-limit";
import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
@@ -115,17 +115,24 @@ function parseBatchItems(csv: string): {
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: the deterministic batch form, no provider on
// the path.
const { user, actor } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
// Per-user limiter, same posture as the compliance routes: the cold
// Per-caller limiter, same posture as the compliance routes: the cold
// build fans out up to 24 rollup walks, so an unthrottled caller could
// hammer the pool past the cache. 30/min covers the dashboard's one
// canonical read plus retries with a wide margin.
//
// v1.37.0 — the bucket keys on the ACTOR, the frozen precedent from
// `medications/compliance`: a manager hammering this route burns their own
// allowance rather than locking the owner out, and cannot collect a fresh
// one by switching records.
const rl = await checkRateLimit(
`insights-derived-batch:${user.id}`,
`insights-derived-batch:${actor.id}`,
30,
60_000,
);
+5 -2
View File
@@ -21,7 +21,7 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireAssistantSurface } from "@/lib/feature-flags";
@@ -69,7 +69,10 @@ const derivedQuerySchema = z.object({
});
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
+4 -2
View File
@@ -26,7 +26,7 @@
import { NextRequest } from "next/server";
import { apiError, apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -43,7 +43,9 @@ type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
+6 -2
View File
@@ -40,7 +40,7 @@ import {
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -106,7 +106,11 @@ const ecgIngestSchema = z
.strict();
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: the record's own recordings, listed without
// touching the encrypted waveform. The POST below is a device ingest and
// keeps `requireAuth()`: a delegate's phone must never move somebody else's
// sync.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
+4 -2
View File
@@ -13,7 +13,7 @@
* beyond auth. Auth via cookie or Bearer.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import {
@@ -24,7 +24,9 @@ import {
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
const plateau = await detectGlp1Plateau(user.id);
+4 -2
View File
@@ -10,7 +10,7 @@
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { readNote } from "@/lib/crypto/note-cipher";
import { NextRequest } from "next/server";
@@ -66,7 +66,9 @@ function parseTagList(raw: string | null): string[] {
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
const url = new URL(request.url);
const rawLimit = Number(url.searchParams.get("limit"));
const limit =
+4 -2
View File
@@ -15,7 +15,7 @@
* MeasurementType, so the wire carries the type tokens only.
*/
import { apiError, apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -28,7 +28,9 @@ import { summariseHealthStatus } from "@/lib/insights/health-status";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
+4 -2
View File
@@ -14,7 +14,7 @@
* session; soft-deleted rows (`deletedAt`) are filtered out.
*/
import { apiError, apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -24,7 +24,9 @@ import { summariseLabChanges } from "@/lib/insights/labs-changes";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
@@ -4,7 +4,7 @@ import {
generateMedicationComplianceStatusForUser,
resolveMedicationComplianceStatusLocale,
} from "@/lib/insights/medication-compliance-status";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
@@ -13,7 +13,10 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
+5 -2
View File
@@ -20,7 +20,7 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
@@ -68,7 +68,10 @@ const metricQuerySchema = z.object({
});
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
+5 -2
View File
@@ -4,7 +4,7 @@ import {
generateMoodStatusForUser,
resolveMoodStatusLocale,
} from "@/lib/insights/mood-status";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
@@ -13,7 +13,10 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
+19 -4
View File
@@ -14,7 +14,7 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";
@@ -26,6 +26,7 @@ import {
type NarrativePeriod,
} from "@/lib/insights/narrative/period-narrative";
import { enqueueNarrativeWarm } from "@/lib/jobs/period-narrative-shared";
import { delegatedGenerationSuppressed } from "@/lib/sharing/delegated-generation";
export const dynamic = "force-dynamic";
@@ -52,7 +53,10 @@ function narrowLocale(locale: Locale): Locale {
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
@@ -87,15 +91,26 @@ export const GET = apiHandler(async (request: NextRequest) => {
// provider-less account (incl. the no-key demo) gets a non-empty
// retrospective on the next read. The enqueue's singletonKey + the 20 h
// freshness window bound this to at most one warm per period per ~day.
//
// v1.37.0 — except on a delegated request. This route warms unconditionally
// rather than on a miss, so without the check a manager's first navigation
// here is an egress of the owner's record that the owner did not ask for.
// The stale row is still served; only the warm is withheld.
let revalidating = false;
if (!isFresh) {
const generationSuppressed = delegatedGenerationSuppressed();
if (!isFresh && !generationSuppressed) {
void enqueueNarrativeWarm({ userId: user.id, period, locale });
revalidating = true;
}
annotate({
action: { name: "insights.narrative" },
meta: { period, has_narrative: existing !== null, revalidating },
meta: {
period,
has_narrative: existing !== null,
revalidating,
generation_suppressed: generationSuppressed,
},
});
return apiSuccess({
+6 -2
View File
@@ -1,7 +1,7 @@
import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -21,7 +21,11 @@ const patternIdSchema = z.string().min(1).max(128);
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE: dismissing a pattern hides a finding on somebody
// else's front door, so it opens at the level that manages the record and
// at no level below. The dismissal stores its own evidence hash and effect
// size, so it reads back and reverses.
const { user } = await requireRecordAuth("manage", "record");
const gate = await requireModuleEnabled(user.id, "insights");
if (!gate.enabled) return gate.response;
const parsedId = patternIdSchema.safeParse((await params).id);
+4 -2
View File
@@ -1,4 +1,4 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
@@ -7,7 +7,9 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
const gate = await requireModuleEnabled(user.id, "insights");
if (!gate.enabled) return gate.response;
const patterns = await prisma.correlationPattern.findMany({
+5 -2
View File
@@ -4,7 +4,7 @@ import {
generatePulseStatusForUser,
resolvePulseStatusLocale,
} from "@/lib/insights/pulse-status";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
@@ -13,7 +13,10 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
+4 -2
View File
@@ -18,7 +18,7 @@
*/
import { z } from "zod";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
@@ -37,7 +37,9 @@ const querySchema = z.object({
});
export const GET = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
+4 -2
View File
@@ -25,7 +25,7 @@
* provider call — this is a pure DB read).
*/
import { apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -44,7 +44,9 @@ const EVENT_TYPE_LIST = Array.from(
) as MeasurementType[];
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
+4 -2
View File
@@ -6,7 +6,7 @@
* walk can be unit-tested without the route shell.
*/
import { apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
@@ -15,7 +15,9 @@ import { buildTargetsResponse } from "@/lib/targets/build-response";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
const { user } = await requireRecordAuth("manage", "record");
// v1.16.8 — stale-while-revalidate read. The cold build is the multi-query
// walk in `buildTargetsResponse`; past the 60 s fresh TTL the prior body
// serves immediately while ONE background rebuild warms the cell. Writes
+5 -2
View File
@@ -4,7 +4,7 @@ import {
generateWeightStatusForUser,
resolveWeightStatusLocale,
} from "@/lib/insights/weight-status";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireAssistantSurface } from "@/lib/feature-flags";
@@ -13,7 +13,10 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE-level read: a generated assessment over the whole
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
await requireAssistantSurface("insightStatus");
+47 -15
View File
@@ -1,6 +1,6 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -10,6 +10,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { overwriteDetails } from "@/lib/sharing/audit-details";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import {
@@ -90,7 +91,10 @@ export const GET = apiHandler(
export const PUT = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Correcting a transcribed lab result is the ordinary
// reason somebody manages a record; the previous numbers ride the audit
// row below.
const { user } = await requireRecordAuth("manage", "labs");
const { id } = await params;
const existing = await prisma.labResult.findFirst({
@@ -116,17 +120,16 @@ export const PUT = apiHandler(
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "labs.update.validation-failed",
details: JSON.stringify({ issues: auditIssues, labResultId: id }),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a manager's malformed payload would read as the owner's own.
void auditLog("labs.update.validation-failed", {
userId: user.id,
details: { issues: auditIssues, labResultId: id },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "labs.update.invalid",
});
@@ -223,7 +226,34 @@ export const PUT = apiHandler(
await auditLog("labResult.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { labResultId: id },
// C4 — the replaced result. The note is named and never quoted: it is
// encrypted on the row.
details: {
labResultId: id,
...overwriteDetails({
before: {
analyte: existing.analyte,
value: existing.value,
valueText: existing.valueText,
unit: existing.unit,
panel: existing.panel,
referenceLow: existing.referenceLow,
referenceHigh: existing.referenceHigh,
takenAt: existing.takenAt,
},
after: {
analyte: updated.analyte,
value: updated.value,
valueText: updated.valueText,
unit: updated.unit,
panel: updated.panel,
referenceLow: updated.referenceLow,
referenceHigh: updated.referenceHigh,
takenAt: updated.takenAt,
},
redacted: d.note !== undefined ? ["note"] : [],
}),
},
});
annotate({ action: { name: "labs.update" }, meta: { labResultId: id } });
@@ -236,7 +266,9 @@ export const PUT = apiHandler(
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Soft-delete with `labs/restore` beside it, so the
// reading is reconstructable from the row itself.
const { user } = await requireRecordAuth("manage", "labs");
const { id } = await params;
const existing = await prisma.labResult.findFirst({
+10 -3
View File
@@ -13,7 +13,7 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
@@ -39,10 +39,17 @@ const restoreSchema = z.object({
export const POST = apiHandler(withIdempotency<[NextRequest]>(postRestore));
async function postRestore(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. The undo beside a delete the level already admits; the
// `where` only ever clears a tombstone the record already carried, so a
// foreign or live id is a no-op.
const { user, actor } = await requireRecordAuth("manage", "labs");
// 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(
`labs:restore:${user.id}`,
`labs:restore:${actor.id}`,
RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS,
);
@@ -18,6 +18,14 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
// v1.37.0 — the mutating arms resolve the RECORD at MANAGE. `actor` is the
// caller and equals `user` for everyone acting on their own reminders,
// which is what this suite exercises.
requireRecordAuth: vi.fn(async () => ({
user: { id: "u1", locale: "en" },
actor: { id: "u1", locale: "en" },
grantId: null,
})),
}));
vi.mock("@/lib/auth/audit", () => ({
@@ -12,6 +12,14 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
// v1.37.0 — the mutating arms resolve the RECORD at MANAGE. `actor` is the
// caller and equals `user` for everyone acting on their own reminders,
// which is what this suite exercises.
requireRecordAuth: vi.fn(async () => ({
user: { id: "u1", locale: "en" },
actor: { id: "u1", locale: "en" },
grantId: null,
})),
}));
vi.mock("@/lib/auth/audit", () => ({
@@ -24,7 +24,7 @@ import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
@@ -36,7 +36,9 @@ const DEFAULT_TIMEZONE = "Europe/Berlin";
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Marking a reminder satisfied re-anchors a schedule the level
// already admits arming and editing.
const { user } = await requireRecordAuth("manage", "measurements");
const { id } = await params;
const existing = await prisma.measurementReminder.findFirst({
+68 -16
View File
@@ -10,7 +10,11 @@ import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
destroyedDetails,
overwriteDetails,
} from "@/lib/sharing/audit-details";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiSuccess,
apiError,
@@ -62,7 +66,9 @@ export const GET = apiHandler(
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Editing a reminder re-anchors when the record's own
// phone rings, which is management of the schedule the level admits.
const { user } = await requireRecordAuth("manage", "measurements");
const { id } = await params;
const existing = await prisma.measurementReminder.findFirst({
@@ -87,17 +93,16 @@ export const PATCH = apiHandler(
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurement-reminders.update.validation-failed",
details: JSON.stringify({ issues: auditIssues, reminderId: id }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a manager's malformed payload would read as the owner's own.
void auditLog("measurement-reminders.update.validation-failed", {
userId: user.id,
details: { issues: auditIssues, reminderId: id },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
@@ -183,7 +188,33 @@ export const PATCH = apiHandler(
await auditLog("measurementReminder.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { reminderId: id },
// C4 — what the edit replaced. A cadence change moves when somebody's
// phone rings; the feed has to be able to say what it used to be.
details: {
reminderId: id,
...overwriteDetails({
before: {
label: existing.label,
measurementType: existing.measurementType,
intervalDays: existing.intervalDays,
rrule: existing.rrule,
anchorDate: existing.anchorDate,
notifyHour: existing.notifyHour,
enabled: existing.enabled,
nextDueAt: existing.nextDueAt,
},
after: {
label: updated.label,
measurementType: updated.measurementType,
intervalDays: updated.intervalDays,
rrule: updated.rrule,
anchorDate: updated.anchorDate,
notifyHour: updated.notifyHour,
enabled: updated.enabled,
nextDueAt: updated.nextDueAt,
},
}),
},
});
annotate({
@@ -197,7 +228,10 @@ export const PATCH = apiHandler(
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE, and the one verb in this family that destroys without
// a tombstone: the row goes, so the audit row below is the only thing left
// that knows what it was for.
const { user } = await requireRecordAuth("manage", "measurements");
const { id } = await params;
const existing = await prisma.measurementReminder.findUnique({
@@ -218,7 +252,25 @@ export const DELETE = apiHandler(
await auditLog("measurementReminder.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { reminderId: id },
// C3 — a hard delete, so the details carry the identity of what went:
// the label the owner wrote, what it watched, when it was next due. An
// id alone would say a preventive-care recall disappeared and never say
// what it was for.
details: {
reminderId: id,
...destroyedDetails({
model: "MeasurementReminder",
id,
label: existing.label,
effectiveAt: existing.nextDueAt,
extra: {
measurementType: existing.measurementType,
intervalDays: existing.intervalDays,
rrule: existing.rrule,
origin: existing.origin,
},
}),
},
});
annotate({
@@ -10,7 +10,7 @@ import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
@@ -22,7 +22,9 @@ const DEFAULT_TIMEZONE = "Europe/Berlin";
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. The same primitive from the checklist side re-anchors a schedule the level
// already admits arming and editing.
const { user } = await requireRecordAuth("manage", "measurements");
const { id } = await params;
const existing = await prisma.measurementReminder.findFirst({
+15 -13
View File
@@ -8,7 +8,7 @@ import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiSuccess,
getClientIp,
@@ -57,7 +57,10 @@ export const GET = apiHandler(async () => {
});
async function postReminder(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. A reminder armed by a manager rings the record's own
// phone, which is the person the reminder is about, and it is additive: the
// row is listed, editable and removable by the owner.
const { user } = await requireRecordAuth("manage", "measurements");
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
@@ -74,17 +77,16 @@ async function postReminder(request: NextRequest): Promise<Response> {
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurement-reminders.create.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a manager's malformed payload would read as the owner's own.
void auditLog("measurement-reminders.create.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
@@ -42,6 +42,7 @@ vi.mock("next/headers", () => ({
import { PUT } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getSession } from "@/lib/auth/session";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
@@ -74,7 +75,11 @@ const EXISTING = {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
// v1.37.0 — the breadcrumb goes through `auditLog()` now (the only writer
// that stamps the actor). `resetAllMocks` clears the factory's resolved
// value and the route chains `.catch()` on the call, so it has to resolve
// again here or every 422 path throws.
vi.mocked(auditLog).mockResolvedValue(undefined as never);
// resetAllMocks clears the factory implementation; the route chains
// `.catch()` on this fire-and-forget call, so it must resolve again.
vi.mocked(invalidateStatusInsightsForTypes).mockResolvedValue(
@@ -136,18 +141,17 @@ describe("PUT /api/measurements/[id] — 422 multi-issue (v1.4.43 W6)", () => {
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("measurements.update.validation-failed");
expect(auditLog).toHaveBeenCalledTimes(1);
const [action, opts] = vi.mocked(auditLog).mock.calls[0] as [
string,
{ userId: string },
];
expect(action).toBe("measurements.update.validation-failed");
expect(opts.userId).toBe("user-1");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down"));
const res = await PUT(
putReq({ value: "junk", measuredAt: "junk" }),
ROUTE_CTX,
+38 -18
View File
@@ -1,7 +1,8 @@
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,
@@ -57,7 +58,10 @@ export const GET = apiHandler(
export const PUT = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Correcting somebody else's reading is management of
// their record, not a contribution to it, and the previous value is gone
// the moment this lands: the audit row below carries it.
const { user } = await requireRecordAuth("manage", "measurements");
const { id } = await params;
@@ -91,20 +95,19 @@ export const PUT = apiHandler(
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.update.validation-failed",
details: JSON.stringify({
issues: auditIssues,
measurementId: id,
}),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a manager's malformed payload would read as the owner's own.
void auditLog("measurements.update.validation-failed", {
userId: user.id,
details: {
issues: auditIssues,
measurementId: id,
},
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
@@ -202,7 +205,21 @@ export const PUT = apiHandler(
await auditLog("measurement.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { measurementId: id },
// C4 — the overwritten scalars. Without them the owner's feed says a
// reading changed and cannot say from what. The note is named and never
// quoted: it is encrypted on the row and copying it here would make the
// audit table a second store for it.
details: {
measurementId: id,
...overwriteDetails({
before: { value: existing.value, measuredAt: existing.measuredAt },
after: {
value: measurement.value,
measuredAt: measurement.measuredAt,
},
redacted: data.notes !== undefined ? ["notes"] : [],
}),
},
});
annotate({
@@ -260,7 +277,10 @@ export const PUT = apiHandler(
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. The row tombstones and `measurements/restore` puts it
// back, so the destruction is reversible by the owner and by whoever did
// it; the audit row already names the reading and its type.
const { user } = await requireRecordAuth("manage", "measurements");
const { id } = await params;
+10 -3
View File
@@ -17,7 +17,7 @@ import { NextRequest } from "next/server";
import { z } from "zod/v4";
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 {
@@ -48,10 +48,17 @@ const bulkDeleteSchema = z.object({
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulkDelete));
async function postBulkDelete(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Every row tombstones and `measurements/restore` puts
// them back, so reconstruction rides the rows themselves rather than the
// audit details.
const { user, actor } = await requireRecordAuth("manage", "measurements");
// 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(
`measurements:bulk-delete:${user.id}`,
`measurements:bulk-delete:${actor.id}`,
RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS,
);
+9 -3
View File
@@ -19,7 +19,7 @@ import { NextRequest } from "next/server";
import { z } from "zod/v4";
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 {
@@ -50,10 +50,16 @@ const restoreSchema = z.object({
export const POST = apiHandler(withIdempotency<[NextRequest]>(postRestore));
async function postRestore(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. A manager who holds the delete holds the undo; the
// `where` below only ever clears a tombstone the record already carried.
const { user, actor } = await requireRecordAuth("manage", "measurements");
// 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(
`measurements:restore:${user.id}`,
`measurements:restore:${actor.id}`,
RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS,
);
+9 -4
View File
@@ -15,7 +15,7 @@
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
@@ -224,17 +224,22 @@ export const GET = apiHandler(
*/
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. A titration step is record data and the dose is what
// the server acts on; administering one is the case the level names.
const { user, actor } = await requireRecordAuth("manage", "medications");
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
// Per-user POST rate-limit — matches the 30/min sibling routes
// Per-caller POST rate-limit — matches the 30/min sibling routes
// (inventory, side-effects). Generous for a hand-driven session,
// tight enough to cut off the spam case.
//
// v1.37.0 — C1: keyed on the ACTOR, so a manager burns their own
// allowance and cannot collect a fresh one by switching records.
const rl = await checkRateLimit(
`medication-glp1:post:${user.id}`,
`medication-glp1:post:${actor.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
@@ -87,6 +87,9 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
// v1.37.0 — `resetAllMocks` clears the factory's resolved value and the
// route chains `.catch()` on this fire-and-forget call.
vi.mocked(auditLog).mockResolvedValue(undefined as never);
vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue({
id: "e1",
userId: "user-1",
@@ -161,19 +164,19 @@ describe("PUT /api/medications/[id]/intake/[eventId] — 422 multi-issue (v1.4.4
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe(
// v1.37.0 — the breadcrumb goes through `auditLog()`, the only writer
// that stamps `actorUserId`.
const breadcrumbs = vi
.mocked(auditLog)
.mock.calls.filter(([name]) => name.endsWith("validation-failed"));
expect(breadcrumbs).toHaveLength(1);
expect(breadcrumbs[0]?.[0]).toBe(
"medications.intake.event.update.validation-failed",
);
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down"));
const res = await PUT(
putReq({ takenAt: "not-iso", skipped: "string" }),
ROUTE_CTX,
@@ -1,7 +1,8 @@
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,10 @@ type RouteParams = { params: Promise<{ id: string; eventId: string }> };
export const PUT = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Correcting a dose tombstones the original and
// re-creates it on the corrected slot, so the write is reconstructable by
// the rows; what it must not do is restamp where the dose came from (C8).
const { user } = await requireRecordAuth("manage", "medications");
const { id, eventId } = await params;
@@ -65,21 +69,20 @@ export const PUT = apiHandler(
event_id: eventId,
},
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.event.update.validation-failed",
details: JSON.stringify({
issues,
medicationId: id,
eventId,
}),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a manager's malformed payload would read as the owner's own.
void auditLog("medications.intake.event.update.validation-failed", {
userId: user.id,
details: {
issues,
medicationId: id,
eventId,
},
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
@@ -307,7 +310,11 @@ export const PUT = apiHandler(
isExplicitTaken: !nextSkipped && nextTakenAt !== null,
isExplicitSkip: nextSkipped,
idempotencyKey: null,
createSource: "WEB",
// C8 — the converged row keeps the ORIGINAL's provenance. This used
// to be a hardcoded `"WEB"`, which said "somebody typed this in the
// browser" about an Apple Health dose whose time was corrected. Under
// MANAGE the sentence would be wrong about a second person as well.
createSource: event.source,
attributionSource,
// v1.16.9 — a slot move is a re-binding, not a new dose: the
// original row's recorded injection site and dose override ride
@@ -339,7 +346,28 @@ export const PUT = apiHandler(
await auditLog("medication.intake.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { eventId, medicationId: id },
// C4 — what the correction moved. A dose edit changes when a dose is
// recorded as taken, which is exactly what a clinician reads back.
details: {
eventId,
medicationId: id,
...overwriteDetails({
before: {
scheduledFor: event.scheduledFor,
takenAt: event.takenAt,
skipped: event.skipped,
},
// The slot-write result carries the canonical four. The injection
// site and the dose override are not compared here because the move
// path copies them onto the converged row by construction — there is
// no edit of them to report.
after: {
scheduledFor: updated.scheduledFor,
takenAt: updated.takenAt,
skipped: updated.skipped,
},
}),
},
});
annotate({
@@ -394,7 +422,9 @@ export const PUT = apiHandler(
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Soft delete: the row stays, the sync feed carries the
// tombstone, and the inventory stamp is refunded.
const { user } = await requireRecordAuth("manage", "medications");
const { id, eventId } = await params;
@@ -1,5 +1,5 @@
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 {
@@ -59,17 +59,22 @@ const BULK_DELETE_WINDOW_MS = 60_000;
*/
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Tombstoning a run of doses; every row stays and the
// sync feed carries each one.
const { user, actor } = await requireRecordAuth("manage", "medications");
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
// v1.5.5 F-1 H-6 — per-user cap matches the glp1 POST so a caller
// v1.5.5 F-1 H-6 — per-caller cap matches the glp1 POST so a caller
// firing repeated 500-id bulk deletes cannot pin the per-dayKey
// recompute path.
//
// v1.37.0 — C1: keyed on the ACTOR, so a manager burns their own
// allowance and cannot collect a fresh one by switching records.
const rl = await checkRateLimit(
`medication-intake-bulk-delete:${user.id}`,
`medication-intake-bulk-delete:${actor.id}`,
BULK_DELETE_RATE_LIMIT,
BULK_DELETE_WINDOW_MS,
);
@@ -59,6 +59,7 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getSession } from "@/lib/auth/session";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -91,6 +92,9 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
// v1.37.0 — `resetAllMocks` clears the factory's resolved value and the
// route chains `.catch()` on this fire-and-forget call.
vi.mocked(auditLog).mockResolvedValue(undefined as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 60,
@@ -179,19 +183,19 @@ describe("POST /api/medications/[id]/intake/import — 422 multi-issue (v1.4.43
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe(
// v1.37.0 — the breadcrumb goes through `auditLog()`, the only writer
// that stamps `actorUserId`.
const breadcrumbs = vi
.mocked(auditLog)
.mock.calls.filter(([name]) => name.endsWith("validation-failed"));
expect(breadcrumbs).toHaveLength(1);
expect(breadcrumbs[0]?.[0]).toBe(
"medications.intake.import.validation-failed",
);
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down"));
const res = await POST(
postReq([{ datum: "not-a-date", uhrzeit: "07:00:00" }]),
ROUTE_CTX,
@@ -1,7 +1,7 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -98,7 +98,12 @@ type RouteParams = { params: Promise<{ id: string }> };
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. The import is additive with duplicates skipped, every
// written row tombstones, and the provenance it stamps (`IMPORT`) stays
// true under delegation because it describes where the data came from and
// not who typed it. C9: the enqueued job outlives this request, so the
// kickoff audit row below joins it by `jobId`.
const { user, actor } = await requireRecordAuth("manage", "medications");
const { id } = await params;
// v1.5.5 C-E3-3 — route ownership through the shared helper so the
@@ -106,8 +111,10 @@ export const POST = apiHandler(
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
// v1.37.0 — C1: keyed on the ACTOR, so a manager burns their own
// allowance and cannot collect a fresh one by switching records.
const rl = await checkRateLimit(
`medications:intake:import:${user.id}`,
`medications:intake:import:${actor.id}`,
IMPORT_RATE_LIMIT_MAX,
IMPORT_RATE_LIMIT_WINDOW_MS,
);
@@ -147,20 +154,19 @@ export const POST = apiHandler(
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.import.validation-failed",
details: JSON.stringify({
issues: auditIssues,
medicationId: id,
}),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a manager's malformed payload would read as the owner's own.
void auditLog("medications.intake.import.validation-failed", {
userId: user.id,
details: {
issues: auditIssues,
medicationId: id,
},
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "medication.intake.import.invalid_format",
});
@@ -1,5 +1,5 @@
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 { apiSuccess, getClientIp } from "@/lib/api-response";
@@ -12,7 +12,10 @@ type RouteParams = { params: Promise<{ id: string }> };
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. It tombstones rather than deletes (since the
// delegated-write release) and drops only rollups that recompute, so the
// history it clears is reconstructable from the rows themselves.
const { user } = await requireRecordAuth("manage", "medications");
const { id } = await params;
// v1.5.5 C-E3-3 — route purge through the same ownership predicate
@@ -21,8 +21,12 @@
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 { auditLog } from "@/lib/auth/audit";
import {
destroyedDetails,
overwriteDetails,
} from "@/lib/sharing/audit-details";
import { annotate } from "@/lib/logging/context";
import {
apiError,
@@ -45,7 +49,8 @@ type RouteParams = { params: Promise<{ id: string; itemId: string }> };
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Correcting a stock item's state or count.
const { user } = await requireRecordAuth("manage", "medications");
const { id, itemId } = await params;
const { data: body, error: jsonError } = await safeJson(request, {
@@ -193,20 +198,46 @@ export const PATCH = apiHandler(
},
});
return { prevState: existing.state, updated };
return {
prevState: existing.state,
// v1.37.0 — the pre-image the audit row carries (C4).
previous: {
unitsRemaining: Number(existing.unitsRemaining),
unitsTotal: Number(existing.unitsTotal),
firstUseAt: existing.firstUseAt,
printedExpiry: existing.printedExpiry,
},
updated,
};
});
if ("notFound" in outcome) return apiError("Inventory item not found", 404);
const { prevState, updated } = outcome;
const { prevState, previous, updated } = outcome;
await auditLog("medication.inventory.update", {
userId: user.id,
ipAddress: getClientIp(request),
// C4 — the counts and dates this edit replaced, beside the state
// transition the row already carried.
details: {
medicationId: id,
itemId,
prevState,
nextState: updated.state,
...overwriteDetails({
before: {
unitsRemaining: previous.unitsRemaining,
unitsTotal: previous.unitsTotal,
firstUseAt: previous.firstUseAt,
printedExpiry: previous.printedExpiry,
},
after: {
unitsRemaining: Number(updated.unitsRemaining),
unitsTotal: Number(updated.unitsTotal),
firstUseAt: updated.firstUseAt,
printedExpiry: updated.printedExpiry,
},
}),
},
});
@@ -231,7 +262,9 @@ export const PATCH = apiHandler(
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE, and a hard delete. The audit row already carried the
// final state and the units left; C3 adds what the container WAS.
const { user } = await requireRecordAuth("manage", "medications");
const { id, itemId } = await params;
// v1.32.22 (M5) — take the same per-medication advisory lock the intake
@@ -255,11 +288,27 @@ export const DELETE = apiHandler(
await auditLog("medication.inventory.delete", {
userId: user.id,
ipAddress: getClientIp(request),
// C3 — the container's own identity: its label, its expiry, what it
// held. The row is gone; this is what is left of it.
details: {
medicationId: id,
itemId,
finalState: existing.state,
unitsRemaining: existing.unitsRemaining,
unitsRemaining: Number(existing.unitsRemaining),
...destroyedDetails({
model: "MedicationInventoryItem",
id: itemId,
label:
existing.doseStrength ??
existing.manufacturer ??
existing.containerType,
effectiveAt: existing.expiresAt ?? existing.printedExpiry,
extra: {
containerType: existing.containerType,
unitsTotal: Number(existing.unitsTotal),
printedExpiry: existing.printedExpiry,
},
}),
},
});
@@ -6,7 +6,8 @@
*
* Per-item operations live on the `[itemId]` sub-route.
*
* Auth: cookie-session via requireAuth(); the medication is verified
* Auth: cookie-session or Bearer, resolved through `requireRecordAuth`; the
* medication is verified
* to belong to the caller before any read/write touches inventory.
* Rate-limit 30/min/user on the POST path (creation is the only
* surface that can spam writes; GET is read-only and benign).
@@ -16,7 +17,7 @@
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 { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
@@ -91,16 +92,22 @@ export const GET = apiHandler(
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Recording the stock the low-stock notification reads;
// that notification rings the record's own phone.
const { user, actor } = await requireRecordAuth("manage", "medications");
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
// Per-user POST rate-limit — 30/min is generous for normal pen
// Per-caller POST rate-limit — 30/min is generous for normal pen
// registrations but cuts off the spam case.
//
// v1.37.0 — C1: the bucket keys on the ACTOR, so a manager burns their
// own allowance rather than the owner's and cannot collect a fresh one by
// switching records.
const rl = await checkRateLimit(
`medication-inventory:post:${user.id}`,
`medication-inventory:post:${actor.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
+83 -2
View File
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth, 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,
@@ -187,7 +188,13 @@ export const GET = apiHandler(
export const PUT = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE, and the strongest candidate in the domain: a manager
// administering somebody's regimen is the case the level exists for. What
// it destroys is scheduling anchors, not history — the tombstone `where`
// below excludes every actioned row — and the previous cadence is the one
// genuinely unrecoverable part, which is what C7 puts in the audit row.
// The DELETE beside it stays refused.
const { user } = await requireRecordAuth("manage", "medications");
const { id } = await params;
// v1.5.5 C-E3-3 — route ownership check through the shared helper
@@ -206,6 +213,10 @@ export const PUT = apiHandler(
active: true,
createdAt: true,
asNeeded: true,
// v1.37.0 — the pre-image the audit row carries (C4).
name: true,
dose: true,
endsOn: true,
_count: { select: { schedules: true } },
},
});
@@ -440,6 +451,17 @@ export const PUT = apiHandler(
};
});
// v1.37.0 — C7. What a schedule replacement destroys, captured for the
// audit row: the cadence that was there and the pending slots that were
// tombstoned with it. A feed line saying "changed a medication" cannot
// answer "back to what?", and unlike a row a schedule keeps ACTING after
// the manager's grant ends.
let replacedCadence: string | null = null;
let replacedScheduleCount: number | null = null;
let tombstonedSlotCount: number | null = null;
let tombstonedFrom: Date | null = null;
let tombstonedTo: Date | null = null;
// If schedules provided, replace all
if (schedules && normalisedSchedules) {
// v1.16.3 — effective dating. Before the wholesale replace below wipes
@@ -467,6 +489,25 @@ export const PUT = apiHandler(
reminderGraceMinutes: row.reminderGraceMinutes,
}),
);
replacedScheduleCount = previousRows.length;
replacedCadence =
previousRows.length > 0
? previousRows
.map((row) =>
[
row.timesOfDay,
row.rrule ?? "",
row.rollingIntervalDays !== null
? `every ${row.rollingIntervalDays}d`
: "",
row.daysOfWeek ?? "",
]
.filter((part) => part !== "")
.join(" "),
)
.join(" | ")
: null;
if (
previousRows.length > 0 &&
schedulesMateriallyDiffer(
@@ -564,6 +605,16 @@ export const PUT = apiHandler(
where: tombstoneWhere,
select: { scheduledFor: true },
});
// C7 — the count and the day range of the anchors that went.
tombstonedSlotCount = tombstoned.length;
for (const row of tombstoned) {
if (tombstonedFrom === null || row.scheduledFor < tombstonedFrom) {
tombstonedFrom = row.scheduledFor;
}
if (tombstonedTo === null || row.scheduledFor > tombstonedTo) {
tombstonedTo = row.scheduledFor;
}
}
await prisma.medicationIntakeEvent.updateMany({
where: tombstoneWhere,
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
@@ -729,7 +780,37 @@ export const PUT = apiHandler(
await auditLog("medication.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { medicationId: id },
// C4 on what the medication row itself lost, and C7 on the schedule:
// the cadence that was replaced, and how many pending slots went with
// it over which days.
details: {
medicationId: id,
...overwriteDetails({
before: {
name: existing.name,
dose: existing.dose,
active: existing.active,
asNeeded: existing.asNeeded,
endsOn: existing.endsOn,
},
after: {
name: medication.name,
dose: medication.dose,
active: medication.active,
asNeeded: medication.asNeeded,
endsOn: medication.endsOn,
},
}),
...(replacedScheduleCount !== null
? {
replacedCadence,
replacedScheduleCount,
tombstonedSlotCount,
tombstonedFrom: tombstonedFrom?.toISOString() ?? null,
tombstonedTo: tombstonedTo?.toISOString() ?? null,
}
: {}),
},
});
annotate({
@@ -25,9 +25,13 @@
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 {
destroyedDetails,
overwriteDetails,
} from "@/lib/sharing/audit-details";
import {
apiError,
apiSuccess,
@@ -37,7 +41,10 @@ import {
} from "@/lib/api-response";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { scheduleRevisionUpdateSchema } from "@/lib/validations/schedule-revision";
import { toRevisionPayloadEntry } from "@/lib/medications/scheduling/schedule-eras";
import {
toRevisionPayloadEntry,
type ScheduleRevisionEntry,
} from "@/lib/medications/scheduling/schedule-eras";
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import type { Prisma } from "@/generated/prisma/client";
@@ -46,7 +53,9 @@ type RouteParams = { params: Promise<{ id: string; revisionId: string }> };
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Correcting an era the compliance engine reads back
// over months of somebody's history.
const { user } = await requireRecordAuth("manage", "medications");
const { id, revisionId } = await params;
const guard = await assertMedicationOwnership(id, user.id);
@@ -116,6 +125,8 @@ export const PATCH = apiHandler(
source: true,
supersededByRevisionId: true,
validUntil: true,
// v1.37.0 — the pre-image the audit row carries (C4).
validFrom: true,
},
});
if (!target || target.medicationId !== id) {
@@ -182,6 +193,10 @@ export const PATCH = apiHandler(
source: true,
} as const;
const previousBounds = {
validFrom: target.validFrom,
validUntil: target.validUntil,
};
if (target.source === "MANUAL") {
const revision = await tx.medicationScheduleRevision.update({
where: { id: revisionId },
@@ -192,7 +207,12 @@ export const PATCH = apiHandler(
},
select: revisionSelect,
});
return { ok: true as const, revision, mode: "in_place" as const };
return {
ok: true as const,
revision,
mode: "in_place" as const,
previousBounds,
};
}
// ARCHIVED — immutable. Mint the correction as a MANUAL row and
@@ -211,7 +231,12 @@ export const PATCH = apiHandler(
where: { id: revisionId },
data: { supersededByRevisionId: revision.id },
});
return { ok: true as const, revision, mode: "supersede" as const };
return {
ok: true as const,
revision,
mode: "supersede" as const,
previousBounds,
};
});
if (!outcome.ok) {
@@ -221,6 +246,9 @@ export const PATCH = apiHandler(
await auditLog("medication.schedule_revision.updated", {
userId: user.id,
ipAddress: getClientIp(request),
// C4 — the era boundaries the correction replaced. An era is a window
// the compliance engine reads months of history through; moving it
// silently re-scores days nobody looked at again.
details: {
medicationId: id,
revisionId,
@@ -228,6 +256,16 @@ export const PATCH = apiHandler(
...(outcome.mode === "supersede" && {
correctionRevisionId: outcome.revision.id,
}),
...overwriteDetails({
before: {
validFrom: outcome.previousBounds.validFrom,
validUntil: outcome.previousBounds.validUntil,
},
after: {
validFrom: outcome.revision.validFrom,
validUntil: outcome.revision.validUntil,
},
}),
},
});
@@ -273,9 +311,32 @@ export const PATCH = apiHandler(
},
);
/**
* v1.37.0 — a one-line rendering of an era's dosing plan, for the audit row
* a hard delete leaves behind (C3). Defensive by construction: a malformed
* payload degrades to an empty string rather than throwing on a delete path.
*/
function summarisePayloadForAudit(payload: unknown): string {
if (!Array.isArray(payload)) return "";
return payload
.map((raw) => {
const entry = (raw ?? {}) as Partial<ScheduleRevisionEntry>;
const times = Array.isArray(entry.timesOfDay)
? entry.timesOfDay.filter((t): t is string => typeof t === "string")
: [];
const dose = typeof entry.dose === "string" ? entry.dose : null;
return [times.join(","), dose].filter(Boolean).join(" ");
})
.filter((part) => part !== "")
.join(" | ");
}
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE, and a hard delete: the era row goes and the
// supersede chain is repaired behind it. C3 below is what makes the era
// readable back out of the feed.
const { user } = await requireRecordAuth("manage", "medications");
const { id, revisionId } = await params;
const guard = await assertMedicationOwnership(id, user.id);
@@ -283,7 +344,16 @@ export const DELETE = apiHandler(
const revision = await prisma.medicationScheduleRevision.findUnique({
where: { id: revisionId },
select: { id: true, medicationId: true, source: true },
// v1.37.0 — the era's own dates and payload summary go into the audit
// row (C3); the row itself does not survive the delete.
select: {
id: true,
medicationId: true,
source: true,
validFrom: true,
validUntil: true,
payload: true,
},
});
if (!revision || revision.medicationId !== id) {
return apiError("Schedule revision not found", 404);
@@ -309,7 +379,20 @@ export const DELETE = apiHandler(
await auditLog("medication.schedule_revision.deleted", {
userId: user.id,
ipAddress: getClientIp(request),
details: { medicationId: id, revisionId },
// C3 — the era's dates and its dosing summary. Without them the feed
// says an era was deleted and the compliance history it explained
// becomes unexplainable.
details: {
medicationId: id,
revisionId,
...destroyedDetails({
model: "MedicationScheduleRevision",
id: revisionId,
label: summarisePayloadForAudit(revision.payload),
effectiveAt: revision.validFrom,
extra: { validUntil: revision.validUntil },
}),
},
});
annotate({
@@ -16,14 +16,15 @@
* min/max of the times) so the era minter — ledger, compliance,
* cadence chips — reads it like any other revision.
*
* Auth: cookie-session or Bearer via requireAuth(); the medication is
* Auth: cookie-session or Bearer, resolved through `requireRecordAuth`; the
* medication is
* verified to belong to the caller before any read or write. Deleting
* a manual era lives in the `[revisionId]` sub-route.
*/
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 {
@@ -135,7 +136,9 @@ export const GET = apiHandler(
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Archiving a schedule era the compliance engine reads:
// additive, audited, and reversible through the DELETE beside it.
const { user } = await requireRecordAuth("manage", "medications");
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
@@ -15,7 +15,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 { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
@@ -38,7 +38,10 @@ async function loadOwnedRow(
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. The one hard delete in the tree whose audit details
// already reconstruct the row: entry, severity and the date it happened
// are on the row this handler writes.
const { user } = await requireRecordAuth("manage", "medications");
const { id, logId } = await params;
const existing = await loadOwnedRow(id, logId, user.id);
@@ -29,7 +29,7 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -97,7 +97,11 @@ function describePlan(plan: AutoExportPlan) {
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. The whole-regimen form of the per-medication import:
// same worker, same additive-with-skip-duplicates properties, same honest
// `IMPORT` provenance. C9: the kickoff audit row below joins the job by id,
// so work that outlives the grant is still attributable.
const { user, actor } = await requireRecordAuth("manage", "medications");
const dryRunParam = new URL(request.url).searchParams.get("dryRun");
const dryRun = dryRunParam === "1" || dryRunParam === "true";
annotate({
@@ -105,8 +109,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
meta: { dry_run: dryRun },
});
// v1.37.0 — C1: keyed on the ACTOR. The bucket is shared with the other
// import surfaces, so keying it on the record would let a manager consume
// the owner's hourly import budget.
const rl = await checkRateLimit(
`import:${user.id}`,
`import:${actor.id}`,
RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS,
);
+18 -4
View File
@@ -18,7 +18,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -151,7 +151,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
export const POST = apiHandler(withIdempotency<[NextRequest]>(postAssessment));
async function postAssessment(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. A screener administered by the manager and transcribed
// into the record: the ordinary way this instrument is used when the person
// it is about cannot run their own record. The row records what the person
// in the room reported; the audit row says who typed it.
const { user, actor } = await requireRecordAuth("manage", "mind");
// Opt-in module (default OFF) — same gate as GET.
const gate = await requireModuleEnabled(user.id, "mentalHealth");
@@ -160,8 +164,11 @@ async function postAssessment(request: NextRequest): Promise<Response> {
// Write rate-limit: a screener is a deliberate, infrequent action, so the
// bucket is tight — it only caps a double-tap / scripted loop minting
// duplicate trend points (the recall window is 2 weeks; 30/hour is generous).
// v1.37.0 — C1: the bucket keys on the ACTOR, so a manager burns their own
// allowance rather than the owner's and cannot collect a fresh one by
// switching records.
const rl = await checkRateLimit(
`mental-health-create:${user.id}`,
`mental-health-create:${actor.id}`,
30,
60 * 60 * 1000,
);
@@ -245,7 +252,14 @@ async function postAssessment(request: NextRequest): Promise<Response> {
const total = scoreTotal(id, items);
const band = severityBand(id, total);
const flagged = isSafetyFlagged(id, items);
const presentedLocale = locale ?? user.locale ?? "en";
// C2 — the locale of the person holding the phone, not of the record.
// `presentedLocale` is used twice: it is stored on the row as what was
// shown, and it selects the crisis-resource copy returned on a positive
// item 9. Under a switch the reader is the MANAGER, and crisis resources in
// a language they cannot read are a safety failure, not a presentation bug.
// `actor` is the caller in every case and equals `user` for everyone acting
// on their own record, so this is the record's own locale for them.
const presentedLocale = locale ?? actor.locale ?? "en";
const when = takenAt ? new Date(takenAt) : new Date();
// Encrypt the per-item answers + the (unscored) functional-impairment follow-up.
@@ -60,6 +60,7 @@ vi.mock("next/headers", () => ({
import { PUT } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getSession } from "@/lib/auth/session";
import {
RatedFactorOutOfRangeError,
@@ -105,7 +106,9 @@ const ROUTE_CTX = { params: Promise.resolve({ id: "me1" }) };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
// v1.37.0 — the breadcrumb goes through `auditLog()` now; `resetAllMocks`
// clears the factory's resolved value and the route chains `.catch()`.
vi.mocked(auditLog).mockResolvedValue(undefined as never);
vi.mocked(prisma.$transaction).mockImplementation(async (callback) =>
callback(txClient as never),
);
@@ -170,17 +173,17 @@ describe("PUT /api/mood-entries/[id] — 422 multi-issue (v1.4.43 W6)", () => {
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(auditLog).toHaveBeenCalledTimes(1);
const [auditAction, auditOpts] = vi.mocked(auditLog).mock.calls[0] as [
string,
{ userId: string },
];
const call = { data: { userId: auditOpts.userId, action: auditAction } };
expect(call.data.action).toBe("mood-entries.update.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down"));
const res = await PUT(
putReq({ mood: "junk", moodLoggedAt: "not-iso" }),
ROUTE_CTX,
+39 -15
View File
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { overwriteDetails } from "@/lib/sharing/audit-details";
import {
apiSuccess,
apiError,
@@ -11,7 +12,7 @@ import {
import { updateMoodEntrySchema, getScoreForMood } from "@/lib/validations/mood";
import { NextRequest } from "next/server";
import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
@@ -62,7 +63,8 @@ export const GET = apiHandler(
export const PUT = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Correcting an entry in a record somebody manages.
const { user } = await requireRecordAuth("manage", "mind");
const { id } = await params;
@@ -97,17 +99,16 @@ export const PUT = apiHandler(
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood-entries.update.validation-failed",
details: JSON.stringify({ issues: auditIssues, moodEntryId: id }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a manager's malformed payload would read as the owner's own.
void auditLog("mood-entries.update.validation-failed", {
userId: user.id,
details: { issues: auditIssues, moodEntryId: id },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "mood.update.invalid",
});
@@ -227,7 +228,29 @@ export const PUT = apiHandler(
await auditLog("moodEntry.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { moodEntryId: id },
// C4 — the replaced fields. The note and the free-text tags are named
// and never quoted; the note is encrypted at rest.
details: {
moodEntryId: id,
...overwriteDetails({
before: {
mood: existing.mood,
score: existing.score,
moodLoggedAt: existing.moodLoggedAt,
},
after: {
mood: entry.mood,
score: entry.score,
moodLoggedAt: entry.moodLoggedAt,
},
redacted: [
...(data.note !== undefined ? ["note"] : []),
...(data.tags !== undefined ? ["tags"] : []),
...(data.tagKeys !== undefined ? ["tagKeys"] : []),
...(data.ratedFactors !== undefined ? ["ratedFactors"] : []),
],
}),
},
});
annotate({
@@ -280,7 +303,8 @@ export const PUT = apiHandler(
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Soft delete with `mood-entries/restore` beside it.
const { user } = await requireRecordAuth("manage", "mind");
const { id } = await params;
@@ -239,17 +239,17 @@ describe("POST /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => {
const res = await POST(postReq({ mood: "junk" }));
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe("mood-entries.create.validation-failed");
// v1.37.0 — the breadcrumb goes through `auditLog()`, the only writer
// that stamps `actorUserId`.
const breadcrumbs = vi
.mocked(auditLog)
.mock.calls.filter(([name]) => name.endsWith("validation-failed"));
expect(breadcrumbs).toHaveLength(1);
expect(breadcrumbs[0]?.[0]).toBe("mood-entries.create.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down"));
const res = await POST(postReq({ mood: "junk" }));
expect(res.status).toBe(422);
});
@@ -17,7 +17,7 @@ import { NextRequest } from "next/server";
import { z } from "zod/v4";
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 {
@@ -43,10 +43,16 @@ const bulkDeleteSchema = z.object({
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulkDelete));
async function postBulkDelete(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Tombstoning a selection; every row stays and `mood-entries/restore`
// puts it back.
const { user, actor } = await requireRecordAuth("manage", "mind");
// 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(
`mood-entries:bulk-delete:${user.id}`,
`mood-entries:bulk-delete:${actor.id}`,
RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS,
);
+9 -3
View File
@@ -19,7 +19,7 @@ import { NextRequest } from "next/server";
import { z } from "zod/v4";
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 {
@@ -45,10 +45,16 @@ const restoreSchema = z.object({
export const POST = apiHandler(withIdempotency<[NextRequest]>(postRestore));
async function postRestore(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. The undo beside a delete the level admits; the `where` only ever
// clears a tombstone the record already carried.
const { user, actor } = await requireRecordAuth("manage", "mind");
// 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(
`mood-entries:restore:${user.id}`,
`mood-entries:restore:${actor.id}`,
RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS,
);
+33 -13
View File
@@ -19,7 +19,7 @@ import {
} from "@/lib/validations/external-id";
import { NextRequest } from "next/server";
import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { withIdempotency } from "@/lib/idempotency";
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
@@ -151,7 +151,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
export const POST = apiHandler(withIdempotency<[NextRequest]>(postMoodEntry));
async function postMoodEntry(request: NextRequest) {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. A mood row entered by a manager is an observation
// transcribed, and the audit trail says so for the retention window. What
// the delegated arm does not get is the sync client's upsert handle; see
// the `externalId` refusal below.
const { user, actor } = await requireRecordAuth("manage", "mind");
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
@@ -177,17 +181,16 @@ async function postMoodEntry(request: NextRequest) {
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood-entries.create.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a manager's malformed payload would read as the owner's own.
void auditLog("mood-entries.create.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "mood.create.invalid",
});
@@ -215,6 +218,23 @@ async function postMoodEntry(request: NextRequest) {
// and the row write agree on the exact value.
const resolvedSource = source ?? "MANUAL";
// C6 — the delegated arm refuses `externalId`. It is the sync client's dedup
// handle: supplying it turns this create into an upsert on
// `(userId, source, externalId)`, which is a device's contract with its own
// rows and not something a second person should be able to aim at somebody
// else's record. A person entering a mood through the UI never sends one.
if (actor.id !== user.id && externalId) {
annotate({
action: { name: "mood-entries.create.external-id-refused" },
meta: { delegated: true },
});
return apiError(
"externalId cannot be supplied on a record you manage",
422,
{ errorCode: "mood.create.external_id_not_delegable" },
);
}
try {
// v1.8.5 — write the entry and its structured-tag links in one
// transaction. The links are user-intended content, not a cache, so a
@@ -12,6 +12,9 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
nutrientIntakeDay: {
// v1.37.0 — the route reads the day total before the upsert so the
// audit row can name what a `set` replaced (C4).
findUnique: vi.fn(),
upsert: vi.fn(),
update: vi.fn(),
},
@@ -101,6 +104,11 @@ beforeEach(() => {
remaining: 59,
resetAt: Date.now() + 60_000,
});
// No stored row unless a test says otherwise; the pre-image read is
// best-effort and a missing day is the ordinary first write.
vi.mocked(prisma.nutrientIntakeDay.findUnique).mockResolvedValue(
null as never,
);
vi.mocked(prisma.nutrientIntakeDay.upsert).mockResolvedValue({
day: "2026-07-16",
nutrient: "water",
+32 -4
View File
@@ -17,9 +17,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 {
apiError,
apiSuccess,
@@ -53,13 +54,18 @@ function isRealCalendarDay(day: string): boolean {
export const POST = apiHandler(withIdempotency<[NextRequest]>(postWater));
async function postWater(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// v1.37.0 — MANAGE. Hydration is a day TOTAL with no per-entry ledger, so a
// `set` overwrites the day with nothing behind it; C4 on the audit row is
// what makes the previous total readable back.
const { user, actor } = await requireRecordAuth("manage", "measurements");
const gate = await requireModuleEnabled(user.id, "nutrients");
if (!gate.enabled) return gate.response;
// v1.37.0 — C1: keyed on the ACTOR, so a manager burns their own allowance
// and cannot collect a fresh one by switching records.
const rl = await checkRateLimit(
`nutrients:water:${user.id}`,
`nutrients:water:${actor.id}`,
WRITE_RATE_LIMIT_MAX,
WRITE_RATE_LIMIT_WINDOW_MS,
);
@@ -103,6 +109,17 @@ async function postWater(request: NextRequest): Promise<Response> {
},
};
// v1.37.0 — the day total as this request found it, for the audit row (C4).
// Read rather than derived: the upsert below is atomic and deliberately does
// not read first, so this is a separate, best-effort pre-image. A concurrent
// write can move the true total between the two statements; what the row
// says is what this caller replaced as far as this request could see, which
// is the honest claim and the one the feed needs.
const before = await prisma.nutrientIntakeDay.findUnique({
where: key,
select: { amount: true },
});
let row = await prisma.nutrientIntakeDay.upsert({
where: key,
create: {
@@ -145,7 +162,18 @@ async function postWater(request: NextRequest): Promise<Response> {
await auditLog("nutrient.water.write", {
userId: user.id,
ipAddress: getClientIp(request),
details: { day, mode, amountMl, resultingAmount: row.amount },
// C4 — the day total that was there before. A `set` replaces it outright
// and no per-entry ledger exists to reconstruct it from.
details: {
day,
mode,
amountMl,
resultingAmount: row.amount,
...overwriteDetails({
before: { dayTotal: before?.amount ?? null },
after: { dayTotal: row.amount },
}),
},
});
annotate({
@@ -360,7 +360,7 @@ describe("linking a document to an illness episode", () => {
// intent and false of the route: the card's list query hit
// `GET /api/documents/inbound`, which resolved the CALLER and 403'd, so
// the card that "stays" rendered a query-error tile. That GET is
// delegable; `delegable-surface-guard.test.ts` freezes it and
// delegable; `sharing-surface-guard.test.ts` freezes it and
// `sharing-delegable-routes.test.ts` drives it.
expect(html).toContain('data-slot="episode-documents-card"');
}
+1 -1
View File
@@ -30,7 +30,7 @@ import {
* no reachable caller. Logging a value on a tracked metric left for the same
* reason one release later: its only form sits on `/custom-metrics/{id}`,
* which a switch closes too.
* `delegable-surface-guard.test.ts` carries that argument
* `sharing-surface-guard.test.ts` carries that argument
* in full and is the list that binds; this paragraph has to agree with it. Editing, deleting, restoring, purging, importing and
* bulk-acting stay with the owner, including on an entry the delegate added
* themselves a minute ago, and so does every create the list does not name
+11 -1
View File
@@ -780,7 +780,7 @@ export interface RecordAuthContext extends AuthContext {
* the fail-closed lever: a delegable route without a classification does not
* typecheck, so the set of routes carrying one cannot fall behind the set of
* routes that are delegable. The value is frozen per module by
* `src/__tests__/delegable-surface-guard.test.ts` and reviewed against the
* `src/__tests__/sharing-surface-guard.test.ts` and reviewed against the
* design's clustering table; this function only enforces what was declared.
*/
export async function requireRecordAuth(
@@ -934,6 +934,16 @@ async function resolveSwitchedRecord(
if (refusal !== null) throw denied(refusal);
getEvent()?.setActingAs(owner.id);
// v1.37.0 — and whether this request may spend the owner's provider budget.
// MANAGE opens the generated reads; it does not open the generation behind
// them, because the owner would pay for and consent to an egress they did
// not cause. A managed profile is the exception and the reason the marker is
// read here rather than the grant: it has no self to protect from its own
// guardian. Stamped where the owner row is already loaded, so no generator
// has to be told; see `src/lib/sharing/delegated-generation.ts`.
if (owner.id !== auth.user.id && owner.managedProfileAt === null) {
getEvent()?.setDelegatedGenerationSuppressed();
}
// Fire-and-forget, the `ApiToken.lastUsedAt` posture: the read must not wait
// on the bookkeeping, and the bookkeeping failing must not fail the read.
void touchGrantUsage(grant.id);
+32 -3
View File
@@ -26,6 +26,21 @@ export interface IllnessDayLogWriteResult {
existed: boolean;
/** The full DTO of the written row — no second round-trip needed. */
dto: IllnessDayLogDTO;
/**
* v1.37.0 — what this write destroyed, for the caller's audit row.
*
* The upsert is a rewrite wearing a create's verb: its UPDATE branch forces
* `deletedAt: null`, so it revives a day the owner deliberately deleted, and
* the symptom write is a full replacement of that day's links. Under MANAGE
* somebody other than the owner can do both, so the two facts that would
* otherwise vanish silently travel back with the result.
*/
replaced: {
/** Symptom keys the day carried before this write, sorted. */
previousSymptomKeys: string[];
/** True when the write brought a tombstoned day back. */
revived: boolean;
};
}
/** A symptom selection carrying its catalog key + optional 0–3 severity. */
@@ -105,7 +120,7 @@ export async function upsertIllnessDayLog(
// run in ONE interactive transaction: no torn state between the link delete
// + create halves, and no second round-trip — the written row (with its
// flattened symptom links) comes straight back as the DTO.
const { row, existed } = await prisma.$transaction(async (tx) => {
const { row, existed, replaced } = await prisma.$transaction(async (tx) => {
const existing = await tx.illnessDayLog.findUnique({
where,
select: {
@@ -113,6 +128,11 @@ export async function upsertIllnessDayLog(
functionalImpact: true,
feverC: true,
noteEncrypted: true,
// v1.37.0 — the two facts the caller's audit row needs. Read inside
// the same transaction as the write, so what is reported is what was
// actually replaced rather than what a second read happened to see.
deletedAt: true,
symptomLinks: { select: { symptom: { select: { key: true } } } },
},
});
@@ -171,8 +191,17 @@ export async function upsertIllnessDayLog(
include: dayLogSymptomInclude,
});
return { row: full, existed: existing !== null };
return {
row: full,
existed: existing !== null,
replaced: {
previousSymptomKeys: (existing?.symptomLinks ?? [])
.map((link) => link.symptom.key)
.sort(),
revived: existing?.deletedAt != null,
},
};
});
return { id: row.id, existed, dto: toIllnessDayLogDTO(row) };
return { id: row.id, existed, dto: toIllnessDayLogDTO(row), replaced };
}
@@ -0,0 +1,109 @@
/**
* v1.37.0 — C5 at the choke point: a delegated read enqueues no generation.
*
* The MANAGE level opens the ten generated reads. Nine of them reach the
* provider through `resolveReadOnlyStatusMiss`, one hop behind a GET that
* looks like a cache read, so a manager's first visit to a metric page used to
* ship the owner's health data to the owner's provider on the owner's budget
* under the owner's consent receipt — with nothing in the response saying so.
*
* The suppression is implemented once, here, rather than in the ten generators
* that call this function, so this file is where it has to be pinned. Both
* directions, because either one alone is worthless: without the positive
* control a broken function that never enqueues anything passes, and without
* the negative one the condition is not tested at all.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const enqueueStatusGeneration = vi.fn(async () => {});
const hasUsableStatusProvider = vi.fn(async () => true);
const statusConsentBlocksGeneration = vi.fn(async () => false);
const suppressed = vi.fn(() => false);
vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({
enqueueStatusGeneration: (...args: unknown[]) =>
enqueueStatusGeneration(...(args as [])),
STATUS_GENERATE_QUEUE: "insight-status-generate",
}));
vi.mock("@/lib/insights/status-provider", () => ({
hasUsableStatusProvider: (...args: unknown[]) =>
hasUsableStatusProvider(...(args as [])),
statusConsentBlocksGeneration: (...args: unknown[]) =>
statusConsentBlocksGeneration(...(args as [])),
}));
vi.mock("@/lib/sharing/delegated-generation", () => ({
delegatedGenerationSuppressed: () => suppressed(),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: {
// The last-good text and the negative-cache probe both read the audit
// ledger; an empty ledger is the cold-cache case this function is for.
auditLog: {
findMany: vi.fn(async () => []),
findFirst: vi.fn(async () => null),
},
},
}));
import { resolveReadOnlyStatusMiss } from "@/lib/insights/status-cache";
beforeEach(() => {
// Every counter, not only the enqueue: the ordering assertion below reads
// the provider probe's call count, and a counter carried over from the
// previous test would make it fail for the wrong reason (or pass for one).
enqueueStatusGeneration.mockClear();
hasUsableStatusProvider.mockClear();
statusConsentBlocksGeneration.mockClear();
suppressed.mockReturnValue(false);
hasUsableStatusProvider.mockResolvedValue(true);
statusConsentBlocksGeneration.mockResolvedValue(false);
});
const ARGS = {
userId: "owner-1",
metric: "WEIGHT" as never,
locale: "en" as never,
};
describe("resolveReadOnlyStatusMiss — the delegated path", () => {
it("enqueues a generation for the person whose record it is", async () => {
// The positive control. If this stops holding, the assertion below stops
// meaning anything: "nothing was enqueued" would be true of a function
// that never enqueues.
const outcome = await resolveReadOnlyStatusMiss(ARGS);
expect(enqueueStatusGeneration).toHaveBeenCalledTimes(1);
expect(outcome.kind).toBe("preparing");
});
it("enqueues nothing while somebody else is holding the request", async () => {
suppressed.mockReturnValue(true);
const outcome = await resolveReadOnlyStatusMiss(ARGS);
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
// Still served, not refused: the read is admitted at MANAGE and only the
// generation behind it is withheld.
expect(outcome.kind).toBe("preparing");
if (outcome.kind === "preparing") {
expect(outcome.revalidating).toBe(false);
}
});
it("decides before it asks whether a provider exists", async () => {
// Ordering matters for the money question, not only for the enqueue: the
// provider probe reads the owner's configuration, and a suppressed
// request has no business touching it.
suppressed.mockReturnValue(true);
await resolveReadOnlyStatusMiss(ARGS);
expect(hasUsableStatusProvider).not.toHaveBeenCalled();
expect(statusConsentBlocksGeneration).not.toHaveBeenCalled();
});
});
+25
View File
@@ -16,6 +16,7 @@ import {
} from "@/lib/insights/correlation-discovery";
import { MAX_CUSTOM_CORRELATION_CHANNELS } from "@/lib/insights/correlation-channel-series";
import { annotate } from "@/lib/logging/context";
import { delegatedGenerationSuppressed } from "@/lib/sharing/delegated-generation";
import type { SupportedLocale } from "@/lib/insights/status-shared";
/**
@@ -559,6 +560,30 @@ export async function resolveReadOnlyStatusMiss(args: {
metric: InsightStatusScope;
locale: SupportedLocale;
}): Promise<ReadOnlyMissOutcome> {
// v1.37.0 — the one gate on the delegated path, and it sits here rather
// than in the ten generators that reach this function precisely because it
// is one gate: a generator added next year inherits it without knowing it
// exists. A manager holding a MANAGE grant may READ the record's generated
// assessments; the miss behind that read must not ship the owner's data to
// the owner's provider on the owner's budget. Served the same way the
// negative-cache branch below serves it: last-good text if there is any,
// `preparing` if there is not, and nothing enqueued either way.
if (delegatedGenerationSuppressed()) {
const lastGood = await readLastGoodStatusText({
userId: args.userId,
cacheAction: statusCacheAction(args.metric, args.locale),
});
annotate({
action: { name: "insights.status.preparing" },
meta: {
metric: args.metric,
suppressed_enqueue: true,
delegated: true,
},
});
return { kind: "preparing", lastGood, revalidating: false };
}
const hasProvider = await hasUsableStatusProvider(args.userId);
if (!hasProvider) return { kind: "no-provider" };
+15
View File
@@ -102,6 +102,21 @@ export class WideEventBuilder {
return this;
}
/**
* v1.37.0 — record that provider generation is closed on this request.
*
* Merges for the same reason `setActingAs` does, and is stamped in the same
* place: the record resolver, which is where the owner row is in hand. It is
* read back through `delegatedGenerationSuppressed()`.
*/
setDelegatedGenerationSuppressed(): this {
this.event.auth = {
...(this.event.auth ?? {}),
delegated_generation: "suppressed",
};
return this;
}
setAction(action: WideEvent["action"]): this {
this.event.action = action;
return this;
+10
View File
@@ -66,6 +66,16 @@ export interface WideEvent {
* dashboard that conflates them cannot answer either.
*/
acting_as?: string;
/**
* v1.37.0 — set when this request is acting on somebody else's record and
* that somebody could have asked for the generation themselves. It is the
* one fact the generated reads consult before enqueuing a provider job:
* a manager's navigation must not spend the owner's budget or ship the
* owner's record to the owner's provider. Absent means "generate as
* usual", which is what every self-request, every guardian request on a
* managed profile and every background job is.
*/
delegated_generation?: "suppressed";
};
// Business-Daten (was wurde getan)
@@ -59,8 +59,15 @@ export interface ApplyCanonicalSlotWriteInput {
* the worker-minted pending row like every other write, and a fresh
* create keeps the historical reminder provenance. `APPLE_HEALTH`
* (v1.28) is the bulk route's HealthKit dose-event import.
*
* v1.37.0 — `IMPORT` joined the union because the per-event edit route
* re-creates a moved dose and must carry the ORIGINAL row's provenance onto
* the converged row (C8). A dose that came in through a history import and
* had its time corrected is still an imported dose; stamping it `WEB`
* because a human moved it is a small lie the compliance history then
* repeats forever.
*/
createSource: "WEB" | "API" | "REMINDER" | "APPLE_HEALTH";
createSource: "WEB" | "API" | "REMINDER" | "APPLE_HEALTH" | "IMPORT";
/**
* v1.36.x — refuse a write that would change an outcome the owner has
* already recorded, instead of applying it last-write-wins.
+127
View File
@@ -0,0 +1,127 @@
/**
* v1.37.0 — the two audit-detail shapes MANAGE is admitted on.
*
* The rule the level ships under is "reconstructable or refused": a manager
* may change and remove things in somebody else's record, and the owner must
* be able to read out of their own activity feed what went missing. The tree
* did not carry that. Every edit filed an id and nothing else — `measurement.
* update` said `{ measurementId }`, `medication.update` said `{ medicationId }`
* — so the feed could say a reading changed and could not say from what, and
* the hard deletes filed the id of a row that no longer exists.
*
* These two helpers are what the admitted verbs use instead. They are small on
* purpose: the audit table is not a second copy of the record and must not
* become one. Three columns, not thirty, and never a decrypted note.
*
* Both return a plain object meant to be SPREAD into a `details` literal at
* the call site rather than to replace it. The literal keeps naming the row's
* own id in the reviewer's line of sight, which is what the structural guard
* reads, and the spread carries the part that is tedious to write by hand and
* easy to get wrong.
*/
/**
* What may enter an audit row.
*
* A closed union rather than `unknown`, because the failure it prevents is the
* one that costs the most: `details: { previous: existing }` is one keystroke
* from `details: { previous: { …, notesEncrypted } }`, and an encrypted note
* copied into the audit table is PHI with a second retention policy and no
* rotation story. A caller with a value outside this union has to decide what
* the feed should say about it, which is the decision this type exists to
* force.
*/
export type AuditScalar = string | number | boolean | Date | null | undefined;
/** The longest string that lands verbatim; anything longer is trimmed. */
const MAX_VALUE_LENGTH = 120;
function coerce(value: AuditScalar): string | number | boolean | null {
if (value === null || value === undefined) return null;
if (value instanceof Date) return value.toISOString();
if (typeof value === "string") {
return value.length > MAX_VALUE_LENGTH
? `${value.slice(0, MAX_VALUE_LENGTH)}…`
: value;
}
return value;
}
function same(a: AuditScalar, b: AuditScalar): boolean {
const left = a instanceof Date ? a.getTime() : (a ?? null);
const right = b instanceof Date ? b.getTime() : (b ?? null);
return left === right;
}
/**
* **C4** — what an overwrite replaced.
*
* `fields` names the family the write touched, so the owner's feed can say
* "changed the value and the time" rather than "changed a reading".
* `previous` carries the values that are gone, for the fields where a handful
* of scalars is the whole fact.
*
* @param before the pre-image, caller-picked field by field. Never the row
* object: picking is the review step, and a spread of the row is how an
* encrypted column gets into an audit table.
* @param after the post-image over the same keys. Fields whose value did not
* move are left out of both halves — an edit that renamed nothing should not
* file a line saying it did.
* @param redacted fields that changed and whose values must never be written:
* free text, anything encrypted at rest. They appear in `fields` and never
* in `previous`, which is the honest answer — the feed says the note was
* rewritten and does not say what it said.
*/
export function overwriteDetails(args: {
before: Record<string, AuditScalar>;
after: Record<string, AuditScalar>;
redacted?: readonly string[];
}): {
fields: string[];
previous: Record<string, string | number | boolean | null>;
} {
const previous: Record<string, string | number | boolean | null> = {};
const fields: string[] = [];
for (const key of Object.keys(args.before)) {
if (same(args.before[key], args.after[key])) continue;
fields.push(key);
previous[key] = coerce(args.before[key]);
}
for (const key of args.redacted ?? []) {
if (!fields.includes(key)) fields.push(key);
}
return { fields: fields.sort(), previous };
}
/**
* **C3** — what a hard delete destroyed.
*
* For the verbs whose domain does not tombstone, where the row is gone the
* moment the transaction commits and the audit row is the only thing left. It
* names the model, the id, a human label and the date the thing was about,
* which is the difference between "a preventive-care reminder was deleted" and
* "the colonoscopy recall for next March was deleted".
*
* `extra` is for the one or two domain facts a label cannot carry — a
* severity, a dose, a count. Scalars only, same reasoning as above.
*/
export function destroyedDetails(args: {
model: string;
id: string;
label: AuditScalar;
effectiveAt: AuditScalar;
extra?: Record<string, AuditScalar>;
}): Record<string, string | number | boolean | null> {
const details: Record<string, string | number | boolean | null> = {
destroyedModel: args.model,
destroyedId: args.id,
destroyedLabel: coerce(args.label),
destroyedAt: coerce(args.effectiveAt),
};
for (const [key, value] of Object.entries(args.extra ?? {})) {
details[key] = coerce(value);
}
return details;
}
+34
View File
@@ -0,0 +1,34 @@
import { getEvent } from "@/lib/logging/context";
/**
* v1.37.0 — whether this request may spend the record owner's provider budget.
*
* MANAGE opens the generated surfaces: a manager opens a metric page and reads
* the assessment that is already there. What it must not open is the hop
* behind that read. Nine of the ten generated reads enqueue a generation job
* on a cache miss (`resolveReadOnlyStatusMiss`) and the narrative enqueues one
* whenever the row is stale, so on a delegated request the sequence is: the
* manager navigates, the owner's health data leaves the box under the owner's
* consent receipt, against the owner's provider, on the owner's budget, and
* the owner sees a bill line for something they did not do. The manager sees a
* card. Nothing on the wire says the two are the same event.
*
* So the read stays and the enqueue goes, on exactly one condition: somebody
* else is holding the request and the record has an owner who could have
* pressed the button themselves. A managed profile has nobody in that
* position — its guardian IS the person who decides what it generates — so the
* suppression does not apply there, and the guardian surfaces that trigger
* generation deliberately are its own list.
*
* Read from the request context rather than from an argument, the same
* reasoning `actingActorFor` states in `src/lib/auth/audit.ts`: the fact is
* settled once, in the resolver, where the owner row is in hand — and threading
* it through ten generator modules would be a second answer to "who is asking",
* with the routes that forgot to pass it being the ones nobody reviewed. A
* caller with no request context (every worker, every cron pass) reads false
* and generates as it always did, which is the correct answer for work nobody
* navigated into.
*/
export function delegatedGenerationSuppressed(): boolean {
return getEvent()?.getAuth()?.delegated_generation === "suppressed";
}
+1 -1
View File
@@ -6,7 +6,7 @@
* holds nothing else: no predicate, no storage, no route knowledge. The
* predicates live in `grants.ts` beside the rest of what a grant means, and
* the per-route classification lives on the call sites, frozen by
* `src/__tests__/delegable-surface-guard.test.ts`.
* `src/__tests__/sharing-surface-guard.test.ts`.
*
* Two properties are the whole reason the file exists separately.
*
@@ -1769,7 +1769,7 @@ describe("GET /api/medications/compliance — the bucket follows the actor", ()
* record, and reading it is much of the point of handing somebody the record.
*
* Five GET arms moved. The upload, the retype, the delete, the episode link
* and every AI verb did not, and `delegable-surface-guard.test.ts` is what
* and every AI verb did not, and `sharing-surface-guard.test.ts` is what
* freezes that split — this file asserts the moved arms actually substitute.
*/
async function enableDocuments(userId: string, enabled = true) {
@@ -448,7 +448,7 @@ writeContract("POST /api/biomarkers", {
* have noticed.
*
* Re-admitting means flipping `requireAuth()` back to `requireRecordAuth`,
* re-listing the route in `delegable-surface-guard.test.ts`, and replacing this
* re-listing the route in `sharing-surface-guard.test.ts`, and replacing this
* block with a `writeContract` — in the same diff as the caregiver-reachable
* surface that made it worth doing.
*/
@@ -0,0 +1,719 @@
/**
* v1.37.0 — the MANAGE perimeter, driven as routes against real Postgres.
*
* MANAGE is the level where a delegate stops adding to somebody's record and
* starts changing it. Seventy modules answer at it; forty-three of them can
* destroy or rewrite something. The structural guard freezes WHICH routes say
* `"manage"` and, since this release, freezes the conditions each admission
* was granted on — but a frozen tag is a promise about code, not about
* behaviour, and the promises here are the kind that fail quietly: an audit
* row that names an id and nothing else, a rate bucket keyed on the wrong
* person, a navigation that spends the owner's AI budget.
*
* So this file drives the shipped exports. Same construction as
* `sharing-delegable-writes.test.ts`: the real `apiHandler`, the real
* `requireRecordAuth`, the real grant table, the real Prisma writes. The grant
* is minted through `inviteGrant` + `acceptGrant` because "a MANAGE grant
* exists" and "a MANAGE grant can be created by the flow this release ships"
* are different claims.
*
* Four legs per verb, then the conditions:
*
* 1. a manager with a live MANAGE grant changes the OWNER's record, and the
* audit row carries `userId = owner` AND `actorUserId = manager`;
* 2. the same person under a WRITE grant is refused, and nothing moved —
* asserted by re-reading the row, not by the status alone;
* 3. a caller who was never granted anything is refused the same way;
* 4. a caller who has not switched gets exactly what they always got.
*
* The conditions each get their own test, and each is written so that removing
* the implementation turns it red rather than leaving it green with a smaller
* claim: the reconstruction legs read the audit `details` and assert the
* VALUES, the scope leg reads the rate-limit table and asserts the key, and
* the generation leg asserts both halves — nothing enqueued for the manager,
* something enqueued for the owner doing the identical thing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
/**
* The narrative warm, stubbed at the seam the condition is about.
*
* C5 is the condition that costs the owner money when it is wrong, and what it
* suppresses is an ENQUEUE — a pg-boss insert whose absence is invisible from
* the response. Counting the calls is the only way to see it, and the count
* has to be taken on both sides: zero for the manager is worth nothing unless
* the same request by the owner is one.
*/
const enqueueNarrativeWarm = vi.fn(async (_payload: unknown) => {});
vi.mock("@/lib/jobs/period-narrative-shared", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/jobs/period-narrative-shared")>();
return {
...actual,
enqueueNarrativeWarm: (payload: unknown) => enqueueNarrativeWarm(payload),
};
});
let counter = 0;
async function makeUser(label: string) {
const suffix = `${label}-${counter++}`;
return getPrismaClient().user.create({
data: {
username: `mg-${suffix}`,
email: `mg-${suffix}@example.test`,
displayName: `MG ${suffix}`,
role: "USER",
timezone: "Europe/Berlin",
locale: "en",
},
});
}
async function signIn(userId: string) {
const session = await getPrismaClient().session.create({
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return session;
}
/** A live grant at the named level, accepted, the caller switched into it. */
async function switchInto(
ownerId: string,
delegateId: string,
access: "READ" | "WRITE" | "MANAGE",
) {
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
const invited = await inviteGrant({
grantorId: ownerId,
granteeId: delegateId,
access,
scope: null,
});
const grant = await acceptGrant({
grantId: invited.id,
granteeId: delegateId,
});
expect(grant.access).toBe(access);
expect(grant.acceptedAt).not.toBeNull();
const session = await signIn(delegateId);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: ownerId },
});
return { grant, session };
}
/** A session inside a record nobody ever shared with this caller. */
async function claimWithoutGrant(ownerId: string, callerId: string) {
const session = await signIn(callerId);
await getPrismaClient().session.update({
where: { id: session.id },
data: { actingAsUserId: ownerId },
});
}
type Handler = (
request: NextRequest,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: { params: Promise<any> },
) => Promise<Response>;
function request(method: string, url: string, body?: unknown): NextRequest {
return new NextRequest(`http://localhost${url}`, {
method,
headers: { "content-type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
}
async function call(
handler: Handler,
method: string,
url: string,
body?: unknown,
params: Record<string, string> = {},
): Promise<Response> {
return handler(request(method, url, body), {
params: Promise.resolve(params),
});
}
async function payload(response: Response): Promise<{
data: unknown;
error: unknown;
meta?: { errorCode?: string };
}> {
return response.json();
}
/** The stable refusal, asserted by code rather than by status alone. */
async function expectDenied(response: Response) {
expect(response.status).toBe(403);
expect((await payload(response)).meta?.errorCode).toBe(
"sharing.access.denied",
);
}
/** The audit row a verb filed, with its details already parsed. */
async function auditRow(action: string) {
const row = await getPrismaClient().auditLog.findFirst({
where: { action },
orderBy: { createdAt: "desc" },
});
expect(row, `no ${action} row was written`).not.toBeNull();
return {
row: row!,
details: JSON.parse(row!.details ?? "{}") as Record<string, unknown>,
};
}
const ISO = "2026-07-15T08:00:00.000Z";
/* -------------------------------------------------------------------------- */
/* The shape of a MANAGE case */
/* -------------------------------------------------------------------------- */
interface ManageCase<C> {
/** Rows the verb acts on, created under the record it will change. */
prepare: (recordOwnerId: string) => Promise<C>;
/** Drive the shipped export against the current session. */
act: (ctx: C) => Promise<Response>;
/** The status a successful call answers with. */
ok: number;
/** The action the happy path files through `auditLog`. */
auditAction: string;
/** True once the verb has taken effect on the prepared rows. */
applied: (ctx: C) => Promise<boolean>;
}
function manageContract<C>(name: string, c: ManageCase<C>) {
describe(name, () => {
it("changes the owner's record and names the manager as actor", async () => {
const owner = await makeUser("owner");
const manager = await makeUser("manager");
const ctx = await c.prepare(owner.id);
await switchInto(owner.id, manager.id, "MANAGE");
const response = await c.act(ctx);
expect(response.status).toBe(c.ok);
// The change landed…
expect(await c.applied(ctx)).toBe(true);
// …and the trail says who made it, under whose record.
const { row } = await auditRow(c.auditAction);
expect(row.userId).toBe(owner.id);
expect(row.actorUserId).toBe(manager.id);
});
it("refuses a WRITE grant, and nothing moves", async () => {
const owner = await makeUser("owner");
const helper = await makeUser("helper");
const ctx = await c.prepare(owner.id);
await switchInto(owner.id, helper.id, "WRITE");
await expectDenied(await c.act(ctx));
// The mutation-check rule: assert it did NOT apply, by re-reading.
expect(await c.applied(ctx)).toBe(false);
});
it("refuses a caller who names a record they were never granted", async () => {
const owner = await makeUser("owner");
const stranger = await makeUser("stranger");
const ctx = await c.prepare(owner.id);
await claimWithoutGrant(owner.id, stranger.id);
await expectDenied(await c.act(ctx));
expect(await c.applied(ctx)).toBe(false);
});
it("is unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
const ctx = await c.prepare(plain.id);
await signIn(plain.id);
expect((await c.act(ctx)).status).toBe(c.ok);
expect(await c.applied(ctx)).toBe(true);
const { row } = await auditRow(c.auditAction);
expect(row.userId).toBe(plain.id);
expect(row.actorUserId).toBeNull();
});
});
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
enqueueNarrativeWarm.mockClear();
});
/* -------------------------------------------------------------------------- */
/* Fixtures */
/* -------------------------------------------------------------------------- */
async function makeMeasurement(userId: string, value = 80) {
// The timestamp moves per row: `(userId, type, measuredAt, source,
// sleepStage)` is unique, so two fixtures on the same instant collide.
const measuredAt = new Date(Date.parse(ISO) + counter++ * 60_000);
return getPrismaClient().measurement.create({
data: {
userId,
type: "WEIGHT",
value,
unit: "kg",
measuredAt,
source: "MANUAL",
},
});
}
async function makeLabResult(userId: string) {
return getPrismaClient().labResult.create({
data: {
userId,
analyte: "HbA1c",
value: 5.2,
unit: "%",
takenAt: new Date(ISO),
},
});
}
async function makeAllergy(userId: string) {
return getPrismaClient().allergy.create({
data: {
userId,
substance: "Penicillin",
category: "MEDICATION",
type: "ALLERGY",
severity: "SEVERE",
status: "ACTIVE",
onsetAt: new Date("2020-03-01T00:00:00.000Z"),
},
});
}
async function makeReminder(userId: string) {
return getPrismaClient().measurementReminder.create({
data: {
userId,
label: "Colonoscopy recall",
measurementType: null,
intervalDays: 365,
notifyHour: 9,
nextDueAt: new Date("2027-03-01T08:00:00.000Z"),
enabled: true,
},
});
}
/* -------------------------------------------------------------------------- */
/* 1 — the four legs, one verb per domain family */
/* -------------------------------------------------------------------------- */
manageContract("PUT /api/measurements/[id]", {
prepare: (ownerId) => makeMeasurement(ownerId),
act: async (row) => {
const { PUT } = await import("@/app/api/measurements/[id]/route");
return call(
PUT as Handler,
"PUT",
`/api/measurements/${row.id}`,
{
value: 74.5,
},
{ id: row.id },
);
},
ok: 200,
auditAction: "measurement.update",
applied: async (row) => {
const after = await getPrismaClient().measurement.findUnique({
where: { id: row.id },
});
return after?.value === 74.5;
},
});
manageContract("DELETE /api/measurements/[id]", {
prepare: (ownerId) => makeMeasurement(ownerId),
act: async (row) => {
const { DELETE } = await import("@/app/api/measurements/[id]/route");
return call(
DELETE as Handler,
"DELETE",
`/api/measurements/${row.id}`,
undefined,
{ id: row.id },
);
},
ok: 200,
auditAction: "measurement.delete",
applied: async (row) => {
const after = await getPrismaClient().measurement.findUnique({
where: { id: row.id },
});
return after?.deletedAt !== null;
},
});
manageContract("PUT /api/labs/[id]", {
prepare: (ownerId) => makeLabResult(ownerId),
act: async (row) => {
const { PUT } = await import("@/app/api/labs/[id]/route");
return call(
PUT as Handler,
"PUT",
`/api/labs/${row.id}`,
{ value: 6.1 },
{
id: row.id,
},
);
},
ok: 200,
auditAction: "labResult.update",
applied: async (row) => {
const after = await getPrismaClient().labResult.findUnique({
where: { id: row.id },
});
return after?.value === 6.1;
},
});
manageContract("PATCH /api/allergies/[id]", {
prepare: (ownerId) => makeAllergy(ownerId),
act: async (row) => {
const { PATCH } = await import("@/app/api/allergies/[id]/route");
return call(
PATCH as Handler,
"PATCH",
`/api/allergies/${row.id}`,
{ severity: "MILD" },
{ id: row.id },
);
},
ok: 200,
auditAction: "allergy.update",
applied: async (row) => {
const after = await getPrismaClient().allergy.findUnique({
where: { id: row.id },
});
return after?.severity === "MILD";
},
});
manageContract("DELETE /api/measurement-reminders/[id]", {
prepare: (ownerId) => makeReminder(ownerId),
act: async (row) => {
const { DELETE } =
await import("@/app/api/measurement-reminders/[id]/route");
return call(
DELETE as Handler,
"DELETE",
`/api/measurement-reminders/${row.id}`,
undefined,
{ id: row.id },
);
},
ok: 200,
auditAction: "measurementReminder.delete",
applied: async (row) => {
const after = await getPrismaClient().measurementReminder.findUnique({
where: { id: row.id },
});
return after === null;
},
});
/* -------------------------------------------------------------------------- */
/* 2 — reconstructable or refused */
/* -------------------------------------------------------------------------- */
describe("what a destruction leaves behind", () => {
it("C3: a hard delete files what it destroyed, and never the encrypted text", async () => {
// The preventive-care reminder is the sharpest case in the tree: the row
// is gone, no restore route reaches it, and the audit row used to say
// `{ reminderId }` — a pointer at nothing. An owner reading their feed
// could see that a recall disappeared and never learn what it was for.
const owner = await makeUser("owner");
const manager = await makeUser("manager");
const reminder = await makeReminder(owner.id);
await switchInto(owner.id, manager.id, "MANAGE");
const { DELETE } =
await import("@/app/api/measurement-reminders/[id]/route");
expect(
(
await call(
DELETE as Handler,
"DELETE",
`/api/measurement-reminders/${reminder.id}`,
undefined,
{ id: reminder.id },
)
).status,
).toBe(200);
const { details } = await auditRow("measurementReminder.delete");
expect(details.destroyedModel).toBe("MeasurementReminder");
expect(details.destroyedId).toBe(reminder.id);
expect(details.destroyedLabel).toBe("Colonoscopy recall");
expect(details.destroyedAt).toBe(reminder.nextDueAt?.toISOString());
expect(details.intervalDays).toBe(365);
});
it("C3: the allergy delete carries the allergen and never the reaction", async () => {
const owner = await makeUser("owner");
const manager = await makeUser("manager");
const allergy = await getPrismaClient().allergy.create({
data: {
userId: owner.id,
substance: "Penicillin",
category: "MEDICATION",
type: "ALLERGY",
severity: "SEVERE",
status: "ACTIVE",
onsetAt: new Date("2020-03-01T00:00:00.000Z"),
},
});
await switchInto(owner.id, manager.id, "MANAGE");
const { DELETE } = await import("@/app/api/allergies/[id]/route");
expect(
(
await call(
DELETE as Handler,
"DELETE",
`/api/allergies/${allergy.id}`,
undefined,
{ id: allergy.id },
)
).status,
).toBe(200);
const { row, details } = await auditRow("allergy.delete");
expect(details.destroyedLabel).toBe("Penicillin");
expect(details.severity).toBe("SEVERE");
expect(details.destroyedAt).toBe("2020-03-01T00:00:00.000Z");
// …and the audit table did not become a second store for the encrypted
// half of the row. Asserted on the raw string, because a nested key would
// slip past a top-level property check.
expect(row.details ?? "").not.toContain("reaction");
});
it("C4: an edit files the field family and the value that is gone", async () => {
const owner = await makeUser("owner");
const manager = await makeUser("manager");
const measurement = await makeMeasurement(owner.id, 80);
await switchInto(owner.id, manager.id, "MANAGE");
const { PUT } = await import("@/app/api/measurements/[id]/route");
expect(
(
await call(
PUT as Handler,
"PUT",
`/api/measurements/${measurement.id}`,
{ value: 74.5 },
{ id: measurement.id },
)
).status,
).toBe(200);
const { details } = await auditRow("measurement.update");
expect(details.fields).toEqual(["value"]);
expect((details.previous as Record<string, unknown>).value).toBe(80);
});
it("a restore puts back the row it names, and leaves the others tombstoned", async () => {
// The fake-that-ignores-its-`where` case: a count check passes on a
// restore that clears every tombstone in the record. The id is what
// defeats it.
const owner = await makeUser("owner");
const manager = await makeUser("manager");
const kept = await makeMeasurement(owner.id, 81);
const other = await makeMeasurement(owner.id, 82);
await getPrismaClient().measurement.updateMany({
where: { id: { in: [kept.id, other.id] } },
data: { deletedAt: new Date() },
});
await switchInto(owner.id, manager.id, "MANAGE");
const { POST } = await import("@/app/api/measurements/restore/route");
const response = await call(
POST as Handler,
"POST",
"/api/measurements/restore",
{
ids: [kept.id],
},
);
expect(response.status).toBe(200);
const restored = await getPrismaClient().measurement.findUnique({
where: { id: kept.id },
});
const untouched = await getPrismaClient().measurement.findUnique({
where: { id: other.id },
});
expect(restored?.deletedAt).toBeNull();
expect(untouched?.deletedAt).not.toBeNull();
const { row } = await auditRow("measurement.restore");
expect(row.userId).toBe(owner.id);
expect(row.actorUserId).toBe(manager.id);
});
});
/* -------------------------------------------------------------------------- */
/* 3 — the conditions that are not about the audit row */
/* -------------------------------------------------------------------------- */
describe("the conditions the admissions were granted on", () => {
it("C1: the manager burns their own rate allowance, not the owner's", async () => {
// The buckets live in Postgres, so the key itself is readable. A bucket
// keyed on the record would let a manager lock the owner out of their own
// restore button by hammering it.
const owner = await makeUser("owner");
const manager = await makeUser("manager");
const row = await makeMeasurement(owner.id);
await getPrismaClient().measurement.update({
where: { id: row.id },
data: { deletedAt: new Date() },
});
await switchInto(owner.id, manager.id, "MANAGE");
const { POST } = await import("@/app/api/measurements/restore/route");
expect(
(
await call(POST as Handler, "POST", "/api/measurements/restore", {
ids: [row.id],
})
).status,
).toBe(200);
const buckets = await getPrismaClient().rateLimit.findMany();
expect(buckets.length).toBeGreaterThan(0);
expect(buckets.some((b) => b.key.includes(manager.id))).toBe(true);
expect(buckets.some((b) => b.key.includes(owner.id))).toBe(false);
});
it("C5: a manager's navigation enqueues no generation; the owner's does", async () => {
// Both halves, and the second is what makes the first mean anything: the
// narrative route warms unconditionally, so a zero on the delegated side
// could just as easily mean the route never reached the enqueue at all.
const owner = await makeUser("owner");
const manager = await makeUser("manager");
const { GET } = await import("@/app/api/insights/narrative/route");
await signIn(owner.id);
expect(
(await call(GET as Handler, "GET", "/api/insights/narrative?period=week"))
.status,
).toBe(200);
expect(enqueueNarrativeWarm).toHaveBeenCalledTimes(1);
enqueueNarrativeWarm.mockClear();
await switchInto(owner.id, manager.id, "MANAGE");
const delegated = await call(
GET as Handler,
"GET",
"/api/insights/narrative?period=week",
);
expect(delegated.status).toBe(200);
expect(enqueueNarrativeWarm).not.toHaveBeenCalled();
});
it("C5: a WRITE grant cannot read the generated surfaces at all", async () => {
const owner = await makeUser("owner");
const helper = await makeUser("helper");
await switchInto(owner.id, helper.id, "WRITE");
const { GET } = await import("@/app/api/insights/narrative/route");
await expectDenied(
await call(GET as Handler, "GET", "/api/insights/narrative?period=week"),
);
expect(enqueueNarrativeWarm).not.toHaveBeenCalled();
});
it("C6: the delegated mood create refuses the sync client's externalId", async () => {
const owner = await makeUser("owner");
const manager = await makeUser("manager");
const body = {
mood: "GUT",
moodLoggedAt: ISO,
// A stable, device-shaped id: the floor on `externalId` refuses an
// unstable one at the schema, and this test is about the level, not
// about that floor.
externalId: "8B0F3C2E-1D4A-4F5B-9C6D-7E8F90A1B2C3",
source: "MOODLOG",
};
await switchInto(owner.id, manager.id, "MANAGE");
const { POST } = await import("@/app/api/mood-entries/route");
const refused = await call(
POST as Handler,
"POST",
"/api/mood-entries",
body,
);
expect(refused.status).toBe(422);
expect((await payload(refused)).meta?.errorCode).toBe(
"mood.create.external_id_not_delegable",
);
expect(
await getPrismaClient().moodEntry.count({ where: { userId: owner.id } }),
).toBe(0);
// The same body from the owner's own session still lands: C6 closes the
// delegated branch, not the route.
await signIn(owner.id);
const accepted = await call(
POST as Handler,
"POST",
"/api/mood-entries",
body,
);
expect(accepted.status).toBe(201);
expect(
await getPrismaClient().moodEntry.count({ where: { userId: owner.id } }),
).toBe(1);
});
});