Merge pull request #699 from MBombeck/work/sharing-routes

Open the classified read routes to a delegate
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-02 20:22:16 +02:00
commit 824545c693
53 files changed
+2120 -210

No files matched your search

+118 -20
View File
@@ -24,12 +24,15 @@
* 1. **Every leg asserts its own matcher found something.** A matcher that
* matches nothing reports success, and this repository has shipped several
* of those — a resolver hidden behind a newline, a regex a variable slipped
* past, an absence asserted in a fixture that never held the thing. The
* frozen lists below start EMPTY, so "found nothing" and "the list is
* correct" look identical from the outside. Each leg therefore pins a
* separate, non-empty count: the number of route modules SCANNED, or the
* number of files naming a symbol the leg depends on. Those counts are the
* evidence; the empty result is only the verdict.
* past, an absence asserted in a fixture that never held the thing. Legs
* (c) and (d) still expect an EMPTY offender set, so for them "found
* nothing" and "the list is correct" look identical from the outside.
* Each leg therefore pins a separate, non-empty count: the number of
* route modules SCANNED, or the number of files naming a symbol the leg
* depends on. Those counts are the evidence; the empty result is only the
* verdict. The delegable list itself was empty when this file was written
* and is not any more, which removes the trap from legs (a) and (b) but
* not from the two below them.
*
* 2. **AST decides, text only skips.** Membership is decided by parsing the
* module and reading its identifiers, so a comment explaining why a route
@@ -234,16 +237,109 @@ function exportedFunctionNames(rel: string): string[] {
/**
* Route modules permitted to act on a record that may not be the caller's.
*
* EMPTY, and correct empty. The resolver landed with the auth layer and
* deliberately touched no route file; the first members arrive when routes
* migrate, each one carrying a line in the classification record that says why
* it may. A route absent from that record cannot enter this list.
* Every member carries a line in the classification record that says why it
* may. A route absent from that record cannot enter this list.
*
* The value is the reason, in one line, in the maintainer's words. Not the
* route's description — the argument for why a delegate reading or writing
* through it cannot extend their own reach.
*
* Note what a member means: the MODULE reaches the resolver, and in every case
* below that is the GET arm alone. The write arms in these same files still
* resolve through `requireAuth()`, which refuses outright while a switch is
* active, so admitting a file here admits a read and nothing more. A delegable
* write is a separate decision that would have to be made at each of them.
*/
const DELEGABLE_ROUTES: Record<string, string> = {};
const DELEGABLE_ROUTES: Record<string, string> = {
"app/api/measurements/route.ts":
"The record's readings. The GET returns values, units, timestamps and notes scoped `userId: user.id` and offers no control affordance; the POST arms in the same file keep `requireAuth()` and so refuse under a switch.",
"app/api/measurements/[id]/route.ts":
"One reading of the record, fetched by id and guarded against the resolved user before it is serialised. The PUT and DELETE beside it keep `requireAuth()`.",
"app/api/measurements/series/route.ts":
"A chart series over the record's own readings. Every `where` and every raw-SQL predicate binds the resolved user id; the route holds no write arm at all.",
"app/api/measurements/series-batch/route.ts":
"The multi-type form of the same series read, delegating to `readSeriesBatch(user.id, …)`. Nothing but the resolved id reaches the query.",
"app/api/measurement-reminders/route.ts":
"The record's own reminder schedule — what the owner asked to be reminded of, not a notification channel or a device. The GET reads `userId: user.id`; creating a reminder stays on `requireAuth()`.",
"app/api/measurement-reminders/[id]/route.ts":
"One reminder of the record, fetched by id then guarded against the resolved user. The PUT and DELETE keep `requireAuth()`.",
"app/api/labs/route.ts":
"The record's lab results. The only nested include is the biomarker's own reference range — no third party, no credential. Creating a result stays on `requireAuth()`.",
"app/api/labs/[id]/route.ts":
"One lab result of the record, fetched by id then guarded against the resolved user before anything is serialised.",
"app/api/biomarkers/route.ts":
"The record's biomarker catalogue: the analytes this account tracks and the ranges it tracks them against. No writes on the read arm.",
"app/api/biomarkers/[id]/route.ts":
"One biomarker of the record, fetch-then-guard against the resolved user.",
"app/api/allergies/route.ts":
"The record's allergy list — the single most useful thing a caregiver can read, and a plain list of the owner's own rows.",
"app/api/allergies/[id]/route.ts":
"One allergy of the record, fetch-then-guard against the resolved user.",
"app/api/family-history/route.ts":
"The record's family history. The payload describes the owner's relatives, so it is the one admitted read where third-party health information is present by design rather than by accident; a caregiver reading it is the use the feature exists for, and the row is stored as the owner's.",
"app/api/family-history/[id]/route.ts":
"One family-history entry of the record, fetch-then-guard against the resolved user.",
"app/api/mental-health/assessments/route.ts":
"The record's screener history. The module gate resolves against the record, so a delegate sees the surface only where the owner switched it on; the rate limit on the POST arm is untouched and that arm still refuses under a switch.",
"app/api/anamnesis/facts/route.ts":
"The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.",
"app/api/mood-entries/route.ts":
"The record's mood entries. The nested include is the tag vocabulary — a key and a kind, no identifiers. Note that `GET /api/mood/tags` is refused and stays refused: it pulls the owner's tag LAYOUT, which is a presentation preference and belongs to the person, not the record.",
"app/api/mood-entries/[id]/route.ts":
"One mood entry of the record, fetch-then-guard against the resolved user.",
"app/api/custom-metrics/route.ts":
"The record's own metric definitions and their latest values. Every `where` carries the resolved user id.",
"app/api/custom-metrics/[id]/route.ts":
"One custom metric of the record, scoped by the resolved user id in the query itself.",
"app/api/custom-metrics/[id]/entries/route.ts":
"The entries of one custom metric, reached only after the metric itself resolves under the record's user id — the ownership hop runs before the entry query.",
"app/api/personal-records/route.ts":
"The record's personal bests, `where: { userId: user.id }`. Nothing else in the file.",
"app/api/sleep/night/route.ts":
"The record's hypnogram. Read-only, module-gated on the record, no write of any kind on the path.",
"app/api/sleep/rhythm/route.ts":
"The record's sleep rhythm, built from `buildSleepRhythm(user.id)` and gated on the record's sleep module.",
"app/api/nutrients/route.ts":
"The record's nutrient overview. It reads the last ingest audit row to date the data — integration-adjacent metadata, and no credential, endpoint or token crosses the wire.",
"app/api/nutrients/daily/route.ts":
"One nutrient's daily series over the record, scoped by the resolved user id.",
"app/api/illness/episodes/route.ts":
"The record's illness episodes. Module-gated on the record; the create arm keeps `requireAuth()`.",
"app/api/illness/episodes/[id]/route.ts":
"One episode of the record, fetch-then-guard against the resolved user.",
"app/api/illness/episodes/[id]/day-logs/route.ts":
"The day logs of one episode. The episode-scoped queries are safe because `loadOwnedEpisode(id, user.id)` runs first and now resolves against the record; the upsert arm keeps `requireAuth()`.",
"app/api/workouts/route.ts":
"The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.",
"app/api/cycle/cycles/route.ts":
"The record's cycle history. Whether cycle tracking applies is a property of the record, and the gate gets there by re-loading the profile and gender for the resolved user id — note that the `gender` argument at the call site is dead (`requireCycleEnabled` names it `_gender`), so it is the id that carries the substitution, not that argument.",
"app/api/cycle/day-logs/route.ts":
'One day of the record\'s cycle log. The `auditLog("cycle.day-log.upsert")` in this file belongs to the PUT, which keeps `requireAuth()`.',
"app/api/cycle/profile/route.ts":
"The record's cycle profile, supplied by the gate itself. This is the one cycle route that does read the auth-context gender directly — `isCycleEnabled(user.gender, gate.profile)` computes the `resolved` flag it returns — and post-substitution that is the owner's.",
"app/api/cycle/symptoms/custom/route.ts":
"The record's own custom symptom vocabulary. The create arm carries the rate limit and keeps `requireAuth()`.",
"app/api/medications/route.ts":
"The record's medication cabinet. The cached list projection keys on the same resolved id it scopes to, so the owner's cell holds the owner's data. Returns no ingest endpoint and no token count — the route that does is refused.",
"app/api/medications/[id]/route.ts":
"One medication of the record, fetch-then-guard against the resolved user before anything is serialised.",
"app/api/medications/[id]/cadence/route.ts":
"One medication's cadence, behind `assertMedicationOwnership(id, user.id)` — the shared guard, resolved against the record.",
"app/api/medications/[id]/schedule-revisions/route.ts":
"One medication's archived schedule eras, behind the same ownership guard. The create arm keeps `requireAuth()` and its compliance backfill with it.",
"app/api/medications/[id]/side-effects/route.ts":
"One medication's recorded side effects, behind the same ownership guard. The rate limit in this file belongs to the create arm.",
"app/api/medications/[id]/inventory/route.ts":
"One medication's inventory, behind the same ownership guard. The rate limit in this file belongs to the create arm.",
"app/api/medications/[id]/phase-config/route.ts":
"One medication's reminder phase config, behind the same ownership guard. The upsert arm keeps `requireAuth()`.",
"app/api/medications/[id]/intake/route.ts":
"One medication's intake history, behind the same ownership guard. The taking of a dose is a write and stays on `requireAuth()`.",
"app/api/medications/intake/route.ts":
"The account-wide intake list, scoped through the resolved user. Its cache cell keys on the same id it scopes to.",
"app/api/medications/compliance/route.ts":
"Batched compliance across the record's cabinet. The one delegable read that spends a quota, so its rate-limit bucket keys on the ACTOR: a delegate must burn their own allowance rather than lock the owner out, and must not collect a fresh one by switching records.",
};
/**
* Route modules that deliberately resolve the ACTOR and keep working under a
@@ -268,7 +364,7 @@ const ACTOR_ROUTES: Record<string, string> = {
* a formality by accident: every addition has to be counted here as well as
* listed above, which is one more place a careless admission has to pass.
*/
const FROZEN_ENTRY_COUNT = 2;
const FROZEN_ENTRY_COUNT = 46;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -382,8 +478,8 @@ describe("the matchers see what they look for", () => {
});
it("the co-occurrence rule fires on a module that does both", () => {
// Leg (c) finds nothing on today's tree because nothing calls the record
// resolver yet. This is the shape it exists to catch, proved against the
// Leg (c) finds nothing on today's tree because no delegable module runs a
// role check. This is the shape it exists to catch, proved against the
// same predicate the tree scan uses.
const OFFENDER = `
import { requireRecordAuth, requireAdmin } from "@/lib/api-handler";
@@ -459,11 +555,13 @@ describe("(a) the delegable route set is frozen", () => {
"app/.well-known/oauth-protected-resource/route.ts",
);
// Positive control for this leg's own matcher, and the reason it exists:
// while the frozen list is empty, "no route names the resolver" and "the
// matcher names nothing" are the same sentence. Neuter `identifiersIn` and
// the assertion below still passes; this one does not. `lib/api-handler.ts`
// is the guaranteed positive — it declares the symbol.
// Positive control for this leg's own matcher. It was written when the
// frozen list was empty and "no route names the resolver" and "the matcher
// names nothing" were the same sentence; the list is populated now, so the
// assertion below would catch a neutered matcher on its own. Kept anyway,
// because a future removal could empty the list again and the day that
// happens is not the day to rediscover this. `lib/api-handler.ts` is the
// guaranteed positive — it declares the symbol.
expect(modulesNaming(["lib/api-handler.ts"], RECORD_RESOLVER)).toEqual([
"lib/api-handler.ts",
]);
@@ -523,7 +621,7 @@ describe("(c) a record resolver never shares a module with a role or step-up che
// This leg's own non-zero proof, and the one that matters most here: the
// offender set is empty today whether or not the helper names are right,
// because nothing calls the record resolver yet. So each helper has to be
// because no delegable module runs a role check. So each helper has to be
// findable in the very tree being scanned. Rename `requireAdmin` and this
// fails, rather than the guard quietly checking for a symbol that no
// longer exists.
+2 -2
View File
@@ -12,7 +12,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -31,7 +31,7 @@ type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
const row = await prisma.allergy.findUnique({ where: { id } });
+2 -2
View File
@@ -11,7 +11,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -29,7 +29,7 @@ import {
import { toAllergyDTO } from "@/lib/records/dto";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const params = new URL(request.url).searchParams;
const parsed = allergyListQuerySchema.safeParse({
@@ -12,6 +12,13 @@ const mocks = vi.hoisted(() => ({
vi.mock("@/lib/api-handler", () => ({
apiHandler: (handler: unknown) => handler,
requireAuth: vi.fn(async () => ({ user: { id: "user-1" } })),
// v1.36.0 — the GET resolves a record rather than a caller. Same shape when
// nobody has switched, which is the case this file exercises.
requireRecordAuth: vi.fn(async () => ({
user: { id: "user-1" },
actor: { id: "user-1" },
grantId: null,
})),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency: (handler: unknown) => handler,
+2 -2
View File
@@ -1,6 +1,6 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -19,7 +19,7 @@ import {
import { healthProfileFactWriteSchema } from "@/lib/validations/health-profile-facts";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const payload = await readHealthProfileFacts(user.id);
annotate({
action: { name: "anamnesis.facts.get" },
+2 -2
View File
@@ -1,6 +1,6 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -64,7 +64,7 @@ function serialiseBiomarker(row: {
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
const row = await prisma.biomarker.findFirst({ where: { id } });
+2 -2
View File
@@ -1,6 +1,6 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -71,7 +71,7 @@ function serialiseBiomarker(row: {
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
// Visible markers first, then hidden — the manager renders them in two
// sections and the lab-entry pickers drop the hidden tail client-side.
@@ -1,6 +1,6 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -35,7 +35,7 @@ type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
const metric = await prisma.customMetric.findFirst({
+2 -2
View File
@@ -1,6 +1,6 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -38,7 +38,7 @@ function effectiveBound(
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
const row = await prisma.customMetric.findFirst({
+2 -2
View File
@@ -1,6 +1,6 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -29,7 +29,7 @@ import { createCustomMetricSchema } from "@/lib/validations/custom-metrics";
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const rows = await prisma.customMetric.findMany({
where: { userId: user.id, deletedAt: null },
+2 -2
View File
@@ -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 { apiError, apiSuccess } from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
@@ -29,7 +29,7 @@ const DEFAULT_LIMIT = 24;
const REGULAR_MAD_THRESHOLD = 2;
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
+2 -2
View File
@@ -14,7 +14,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -48,7 +48,7 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postDayLog));
* row id. Gated + owner-scoped + `deletedAt:null`.
*/
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
+2 -2
View File
@@ -4,7 +4,7 @@
* Gated like every cycle route; returns the lazily-upserted profile with
* the resolved `cycleTrackingEnabled`.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
@@ -14,7 +14,7 @@ import { toCycleProfileDTO } from "@/lib/cycle/dto";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
+2 -2
View File
@@ -24,7 +24,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 { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -41,7 +41,7 @@ import {
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
+2 -2
View File
@@ -10,7 +10,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -29,7 +29,7 @@ type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
const row = await prisma.familyHistoryEntry.findUnique({ where: { id } });
+2 -2
View File
@@ -12,7 +12,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -30,7 +30,7 @@ import {
import { toFamilyHistoryEntryDTO } from "@/lib/records/dto";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const params = new URL(request.url).searchParams;
const parsed = familyHistoryListQuerySchema.safeParse({
@@ -16,7 +16,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -55,7 +55,7 @@ async function loadOwnedEpisode(
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
+2 -2
View File
@@ -10,7 +10,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -30,7 +30,7 @@ type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
+2 -2
View File
@@ -13,7 +13,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -33,7 +33,7 @@ import {
import { toIllnessEpisodeDTO } from "@/lib/illness/dto";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
+2 -2
View File
@@ -1,6 +1,6 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -65,7 +65,7 @@ function toResolved(
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
const row = await prisma.labResult.findFirst({
+2 -2
View File
@@ -1,7 +1,7 @@
import { NextRequest } from "next/server";
import { fireAndForget } from "@/lib/logging/fire-and-forget";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -70,7 +70,7 @@ function toResolved(
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const params = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listLabResultsSchema.safeParse(params);
@@ -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, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiSuccess,
apiError,
@@ -41,7 +41,7 @@ async function resolveTimezone(userId: string): Promise<string> {
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
const reminder = await prisma.measurementReminder.findFirst({
+2 -2
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 } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiSuccess,
getClientIp,
@@ -36,7 +36,7 @@ async function resolveTimezone(userId: string): Promise<string> {
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const reminders = await prisma.measurementReminder.findMany({
where: { userId: user.id, deletedAt: null },
+2 -2
View File
@@ -1,5 +1,5 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -27,7 +27,7 @@ type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
@@ -54,6 +54,7 @@ vi.mock("next/headers", () => ({
}));
import { GET } from "../route";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
@@ -80,6 +81,9 @@ beforeEach(() => {
// write; the route swallows rejections so the test only needs a
// resolved mock to keep the catch-block silent.
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
// v1.36.0 — the 422 breadcrumb writes through the `auditLog` helper, whose
// returned promise the route attaches a `.catch` to.
vi.mocked(auditLog).mockResolvedValue(undefined);
// v1.29.6 — null source-priority blob → default rank ladders for the
// groupBy=day canonical-source collapse.
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
@@ -30,9 +30,7 @@ vi.mock("@/lib/db", () => ({
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
@@ -53,6 +51,7 @@ vi.mock("next/headers", () => ({
}));
import { GET, POST } from "../route";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { Prisma } from "@/generated/prisma/client";
@@ -71,6 +70,11 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
// v1.36.0 — the GET's validation breadcrumb goes through the `auditLog`
// helper now, because that helper is the only writer that stamps
// `actorUserId`. `resetAllMocks` above clears the factory's implementation,
// so the resolved promise the route awaits has to be restored here.
vi.mocked(auditLog).mockResolvedValue(undefined);
});
function getReq(qs: string): NextRequest {
@@ -121,18 +125,18 @@ describe("GET /api/measurements — 422 multi-issue (v1.4.43 W6)", () => {
const res = await GET(getReq("limit=999999&offset=-1"));
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; details: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("measurements.list.validation-failed");
expect(auditLog).toHaveBeenCalledTimes(1);
// v1.36.0 — through the helper, which files the row under the resolved
// record and stamps the delegate as actor when there is one. The bare
// `prisma.auditLog.create` this replaced could not do the second half.
expect(auditLog).toHaveBeenCalledWith(
"measurements.list.validation-failed",
expect.objectContaining({ userId: "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 GET(getReq("limit=999999&offset=-1"));
expect(res.status).toBe(422);
});
+12 -13
View File
@@ -1,5 +1,5 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { fireAndForget } from "@/lib/logging/fire-and-forget";
import { auditLog } from "@/lib/auth/audit";
@@ -58,7 +58,7 @@ import type {
import { Prisma } from "@/generated/prisma/client";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const params = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listMeasurementsSchema.safeParse(params);
@@ -78,17 +78,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.list.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
// v1.36.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only thing that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a delegate's malformed query would read as the owner's own.
void auditLog("measurements.list.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
@@ -22,7 +22,7 @@
* client keeps sleep on the dedicated route.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { returnAllZodIssues } from "@/lib/api-response";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
@@ -33,7 +33,7 @@ import {
} from "@/lib/validations/series-batch";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const params = Object.fromEntries(request.nextUrl.searchParams);
const parsed = seriesBatchQuerySchema.safeParse(params);
+13 -13
View File
@@ -11,7 +11,8 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiSuccess,
buildPayloadDiagnostic,
@@ -159,7 +160,7 @@ function stdDev(values: number[]): number {
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const rawQuery = Object.fromEntries(request.nextUrl.searchParams);
const parsed = querySchema.safeParse(rawQuery);
@@ -193,17 +194,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.series.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
// v1.36.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only thing that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a delegate's malformed query would read as the owner's own.
void auditLog("measurements.series.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
@@ -18,7 +18,7 @@ import { NextRequest } from "next/server";
import { z } from "zod";
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 { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import {
@@ -42,7 +42,7 @@ const querySchema = z.object({
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
@@ -72,9 +72,7 @@ vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
@@ -100,6 +98,7 @@ vi.mock("@/lib/notifications/medication-intake-sync", () => ({
import { GET, POST } from "../route";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
@@ -127,6 +126,10 @@ const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
beforeEach(() => {
vi.resetAllMocks();
// v1.36.0 — the GET's validation breadcrumb goes through the `auditLog`
// helper, whose promise the route attaches a `.catch` to. `resetAllMocks`
// clears the factory's implementation, so it is restored here.
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
@@ -292,13 +295,19 @@ describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
await GET(makeRequest("status=junk"), ROUTE_PARAMS);
await POST(postReq({ takenAt: "junk" }), ROUTE_PARAMS);
await new Promise((r) => setTimeout(r, 5));
// v1.36.0 — the GET's breadcrumb goes through the `auditLog` helper (the
// only writer that stamps `actorUserId`); the POST's still writes the row
// directly, because that arm cannot be reached under a switch.
expect(auditLog).toHaveBeenCalledWith(
"medications.intake.list.validation-failed",
expect.objectContaining({ userId: "user-1" }),
);
expect(prisma.auditLog.create).toHaveBeenCalled();
const actions = vi
.mocked(prisma.auditLog.create)
.mock.calls.map(
(c) => (c[0] as { data: { action: string } }).data.action,
);
expect(actions).toContain("medications.intake.list.validation-failed");
expect(actions).toContain("medications.intake.create.validation-failed");
});
+12 -16
View File
@@ -1,5 +1,5 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -538,7 +538,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
@@ -559,20 +559,16 @@ export const GET = apiHandler(
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.list.validation-failed",
details: JSON.stringify({
issues: auditIssues,
medicationId: id,
}),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
// v1.36.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only thing that stamps
// `actorUserId`. Filed under the resolved record either way; without
// the stamp a delegate's malformed query would read as the owner's own.
void auditLog("medications.intake.list.validation-failed", {
userId: user.id,
details: { issues: auditIssues, medicationId: id },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
@@ -16,7 +16,7 @@
import { NextRequest } from "next/server";
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 {
@@ -44,7 +44,7 @@ const POST_WINDOW_MS = 60_000;
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
@@ -1,5 +1,5 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { phaseConfigSchema } from "@/lib/validations/phase-config";
@@ -10,7 +10,7 @@ type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
+2 -2
View File
@@ -1,5 +1,5 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -76,7 +76,7 @@ function reconcileScheduleWindow(
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
const medication = await prisma.medication.findUnique({
@@ -23,7 +23,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -73,7 +73,7 @@ function summariseEntries(payload: unknown): Array<{
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
@@ -19,7 +19,7 @@
import { NextRequest } from "next/server";
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 {
@@ -45,7 +45,7 @@ const POST_WINDOW_MS = 60_000;
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
+8 -3
View File
@@ -1,7 +1,7 @@
import pLimit from "p-limit";
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 { apiSuccess, apiError } from "@/lib/api-response";
import {
@@ -26,10 +26,15 @@ import { checkRateLimit } from "@/lib/rate-limit";
* detail page keeps the per-id route for the grid.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const { user, actor } = await requireRecordAuth("read");
// v1.36.0 — the bucket keys on the ACTOR while everything below it scopes to
// the record. Two reasons, and both are the same reason from opposite ends:
// a delegate hammering this route must burn their own allowance rather than
// locking the owner out of their own cabinet, and switching records must not
// hand the same caller a fresh allowance. `user.id` would get both wrong.
const rl = await checkRateLimit(
`medication-compliance-summary:${user.id}`,
`medication-compliance-summary:${actor.id}`,
30,
60_000,
);
@@ -44,9 +44,7 @@ vi.mock("@/lib/jobs/boss-instance", () => ({
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/medications/inventory/consumption", () => ({
consumeForIntake: vi.fn().mockResolvedValue(undefined),
@@ -76,6 +74,7 @@ vi.mock("next/headers", () => ({
}));
import { GET, POST } from "../route";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import {
@@ -92,6 +91,10 @@ const SESSION_OK = {
beforeEach(() => {
vi.resetAllMocks();
// v1.36.0 — the GET's validation breadcrumb goes through the `auditLog`
// helper, whose promise the route attaches a `.catch` to. `resetAllMocks`
// clears the factory's implementation, so it is restored here.
vi.mocked(auditLog).mockResolvedValue(undefined);
// v1.4.34 IW-G — reset compliance LRU between tests so each case
// observes a cold cache.
__resetAllCachesForTests();
+12 -13
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, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -84,7 +84,7 @@ const updateSchema = z.object({
});
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const parsed = querySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams),
@@ -102,17 +102,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.list.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
// v1.36.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only thing that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a delegate's malformed query would read as the owner's own.
void auditLog("medications.intake.list.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -33,7 +33,7 @@ import { NextRequest } from "next/server";
const APPLE_HEALTH_MIRROR_CAP = 30;
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
// Cache the list shape on userId. The 60 s fresh TTL bounds the
// cross-midnight staleness window for `todayEventCount` and the
@@ -18,7 +18,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -99,7 +99,7 @@ function respondExisting(row: Parameters<typeof shapeRow>[0]): Response {
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
// Opt-in module (default OFF): the screener history is unreachable until the
// account turns the mental-health module on. Enforced server-side even for a
// valid Bearer token (the nav entry / page redirect are UX-only).
+2 -2
View File
@@ -11,7 +11,7 @@ import {
import { updateMoodEntrySchema, getScoreForMood } from "@/lib/validations/mood";
import { NextRequest } from "next/server";
import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, 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";
@@ -36,7 +36,7 @@ function parseTags(tags: string | null): string[] {
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
@@ -51,9 +51,7 @@ vi.mock("@/lib/mood/tag-links", async () => {
});
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
@@ -80,6 +78,7 @@ vi.mock("next/headers", () => ({
}));
import { GET, POST } from "../route";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { createTagLinks } from "@/lib/mood/tag-links";
@@ -107,6 +106,11 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
// v1.36.0 — the GET's validation breadcrumb goes through the `auditLog`
// helper now, because that helper is the only writer that stamps
// `actorUserId`. `resetAllMocks` clears the factory's implementation, so the
// resolved promise the route attaches a `.catch` to is restored here.
vi.mocked(auditLog).mockResolvedValue(undefined);
// Re-establish the interactive-transaction passthrough after the reset.
vi.mocked(prisma.$transaction).mockImplementation(async (fn: unknown) =>
(fn as (tx: typeof txClient) => unknown)(txClient),
@@ -152,17 +156,14 @@ describe("GET /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => {
const res = await GET(getReq("mood=junk&sortBy=garbage"));
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.list.validation-failed");
expect(auditLog).toHaveBeenCalledWith(
"mood-entries.list.validation-failed",
expect.objectContaining({ userId: "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 GET(getReq("mood=junk&sortBy=garbage"));
expect(res.status).toBe(422);
});
+12 -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 } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { withIdempotency } from "@/lib/idempotency";
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
@@ -41,7 +41,7 @@ function parseTags(tags: string | null): string[] {
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const params = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listMoodEntriesSchema.safeParse(params);
@@ -61,17 +61,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood-entries.list.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
// v1.36.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only thing that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a delegate's malformed query would read as the owner's own.
void auditLog("mood-entries.list.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "mood.list.invalid",
});
+2 -2
View File
@@ -19,7 +19,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 { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { binaryReferenceSex } from "@/lib/profile/sex";
@@ -34,7 +34,7 @@ import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const gate = await requireModuleEnabled(user.id, "nutrients");
if (!gate.enabled) return gate.response;
+2 -2
View File
@@ -17,7 +17,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 { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -92,7 +92,7 @@ function pickTopSkipReason(byReason: Record<string, number>): string | null {
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const gate = await requireModuleEnabled(user.id, "nutrients");
if (!gate.enabled) return gate.response;
+2 -2
View File
@@ -16,7 +16,7 @@
*/
import { prisma } from "@/lib/db";
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { measurementTypeEnum } from "@/lib/validations/measurement";
@@ -36,7 +36,7 @@ const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 500;
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
annotate({ action: { name: "personalRecords.list" } });
const { searchParams } = new URL(request.url);
+2 -2
View File
@@ -24,7 +24,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 {
apiSuccess,
returnAllZodIssues,
@@ -105,7 +105,7 @@ function serializeSession(s: SleepSession) {
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
// Per-domain gate: the hypnogram read serves only the sleep module's
// view surfaces, so it gates on the sleep module. Disabled ⇒ 403.
+2 -2
View File
@@ -19,14 +19,14 @@
*
* Auth: cookie session OR Bearer token (`requireAuth`). Soft-delete-filtered.
*/
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 { buildSleepRhythm } from "@/lib/insights/derived/sleep-rhythm";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
// Per-domain gate: the sleep-rhythm read serves only the sleep module's
// view surfaces (Sleep page + iOS), so it gates on the sleep module.
+2 -2
View File
@@ -57,7 +57,7 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import {
@@ -70,7 +70,7 @@ const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 200;
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
annotate({ action: { name: "workouts.list" } });
// v1.18.0 B1 — the workouts surface is a toggleable module. A disabled
@@ -15,15 +15,24 @@
* into the second, and the owner's revoke button would mean "at some point
* later" instead of "now".
*
* The do-no-harm arm runs against a REAL shipped route (`GET /api/measurements`,
* a bare-`requireAuth` handler nobody has migrated). A mock cannot fake that,
* and the arm is worth nothing unless the route it exercises is one somebody
* actually ships.
* Both arms run against REAL shipped routes, and since the route migration they
* exercise both sides of the boundary rather than one:
*
* The delegable handler is assembled here rather than imported because no route
* declares `requireRecordAuth` yet — S3 lands the frozen list empty on purpose,
* and S6 migrates the first route into it. Everything under it is real: the
* real `apiHandler`, the real resolver, the real grant module, the real
* * `GET /api/measurements` is delegable now. Un-switched it must answer
* exactly what it answered before the resolver existed, which is the
* do-no-harm claim and the one worth the most.
* * `GET /api/share-links` is refused, permanently and by design — a delegate
* must never mint a door that outlives their own revocation. It is what a
* route that never declared a mode does under a switch. Naming a
* permanently-refused route rather than a merely-unmigrated one is
* deliberate: this arm broke once already when the route it pointed at
* became delegable, and a route that can never join the list cannot break
* it the same way twice.
*
* The delegable handler below is still assembled here rather than imported. A
* shipped route would work, but it would bind this file to one domain's query
* shape, and what is under test is the resolver. Everything beneath it is real:
* the real `apiHandler`, the real resolver, the real grant module, the real
* database.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -275,7 +284,7 @@ describe("acting-account resolver — the decision is never cached", () => {
});
describe("acting-account resolver — do no harm", () => {
it("leaves an un-switched request on a real shipped route exactly as it was", async () => {
it("leaves an un-switched request on a real delegable route exactly as it was", async () => {
const user = await makeUser("solo");
await seedWeight(user.id, 72);
await signIn(user.id);
@@ -291,7 +300,7 @@ describe("acting-account resolver — do no harm", () => {
).toEqual([72]);
});
it("refuses that same route while a switch is active, and serves nobody's rows", async () => {
it("serves the owner's rows on that route once the caller has switched", async () => {
const { owner, delegate } = await household();
const session = await signIn(delegate.id);
await switchTo(session.id, owner.id);
@@ -300,13 +309,32 @@ describe("acting-account resolver — do no harm", () => {
const response = await GET(
new NextRequest("http://localhost/api/measurements?type=WEIGHT"),
);
expect(response.status).toBe(200);
const body = await response.json();
// The owner's 81 kg, and — the failure that matters — not the delegate's
// own 64 kg, which is seeded alongside it.
expect(
body.data.measurements.map((m: { value: number }) => m.value),
).toEqual([81]);
});
it("refuses a route that never declared a mode, and serves nobody's rows", async () => {
// `GET /api/share-links` is permanently non-delegable: a delegate who
// could mint a clinician link would hold a door that survives the owner
// revoking their access. It resolves through the bare `requireAuth`
// default arm, which is what makes the refusal structural rather than a
// check somebody remembered to write.
const { owner, delegate } = await household();
const session = await signIn(delegate.id);
await switchTo(session.id, owner.id);
const { GET } = await import("@/app/api/share-links/route");
const response = await GET();
expect(response.status).toBe(403);
const body = await response.json();
expect(body.meta.errorCode).toBe("sharing.not_permitted");
expect(body.data).toBeNull();
// Not the owner's rows, and — the failure that matters — not the
// delegate's own either.
expect(JSON.stringify(body)).not.toContain("64");
expect(owner.id).not.toBe(delegate.id);
});
});
File diff suppressed because it is too large. Load diff