feat(sharing): open the mood, custom-metric and record reads to a delegate

Six routes: the mood-entry list and detail, the custom-metric list, detail
and entries, and the personal-record list. The custom-metric entries read
establishes ownership on the metric before it queries by metric id, and that
hop now resolves against the record — a delegate cannot reach their own
metric from inside somebody else's account.

`GET /api/mood/tags` stays refused. It looks like plain record vocabulary and
one select in the middle of it pulls the owner's tag layout, which is a
presentation preference and belongs to the person rather than the record.

The mood list's validation breadcrumb moves onto `auditLog()` for the actor
stamp, the same change the measurement reads took.
This commit is contained in:
Marc-André Bombeck committed 2026-08-02 16:44:52 +02:00
1 parent af0f749740
commit 8c070c953b
9 files changed
+258 -35

No files matched your search

+13 -1
View File
@@ -280,6 +280,18 @@ const DELEGABLE_ROUTES: Record<string, string> = {
"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.",
};
/**
@@ -303,7 +315,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 = 17;
const FROZEN_ENTRY_COUNT = 23;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -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
@@ -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
@@ -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);
@@ -840,3 +840,214 @@ describe("GET /api/mental-health/assessments", () => {
expect(payload.assessments.map((a) => a.totalScore)).toEqual([7]);
});
});
/* -------------------------------------------------------------------------- */
/* Mood, custom metrics, personal records */
/* -------------------------------------------------------------------------- */
async function seedMood(userId: string, score: number) {
await getPrismaClient().moodEntry.create({
data: {
userId,
date: "2026-07-14",
mood: "GUT",
score,
moodLoggedAt: new Date("2026-07-14T19:00:00Z"),
},
});
}
contract<number>("GET /api/mood-entries", {
call: async () => {
const { GET } = await import("@/app/api/mood-entries/route");
return call(GET as Handler, "/api/mood-entries");
},
seed: seedMood,
read: (payload: { entries: { score: number }[] }) =>
payload.entries.map((e) => e.score),
ownerMarker: 4,
delegateMarker: 2,
});
describe("GET /api/mood-entries/[id]", () => {
it("resolves against the owner and refuses once the grant ends", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedMood(owner.id, 4);
await seedMood(delegate.id, 2);
const ownerRow = await getPrismaClient().moodEntry.findFirstOrThrow({
where: { userId: owner.id },
});
const delegateRow = await getPrismaClient().moodEntry.findFirstOrThrow({
where: { userId: delegate.id },
});
const { GET } = await import("@/app/api/mood-entries/[id]/route");
const { grant } = await switchInto(owner.id, delegate.id);
const mine = await call(
GET as Handler,
`/api/mood-entries/${ownerRow.id}`,
{ id: ownerRow.id },
);
expect(mine.status).toBe(200);
expect((await body(mine)).data).toMatchObject({ score: 4 });
const theirs = await call(
GET as Handler,
`/api/mood-entries/${delegateRow.id}`,
{ id: delegateRow.id },
);
expect(theirs.status).toBe(404);
await revokeGrant(grant.id);
await expectDenied(
await call(GET as Handler, `/api/mood-entries/${ownerRow.id}`, {
id: ownerRow.id,
}),
);
});
it("is unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
await seedMood(plain.id, 5);
const row = await getPrismaClient().moodEntry.findFirstOrThrow({
where: { userId: plain.id },
});
await signIn(plain.id);
const { GET } = await import("@/app/api/mood-entries/[id]/route");
const response = await call(GET as Handler, `/api/mood-entries/${row.id}`, {
id: row.id,
});
expect(response.status).toBe(200);
expect((await body(response)).data).toMatchObject({ score: 5 });
});
});
async function seedCustomMetric(userId: string, name: string) {
const metric = await getPrismaClient().customMetric.create({
data: { userId, name, unit: "ml" },
});
await getPrismaClient().customMetricEntry.create({
data: {
userId,
customMetricId: metric.id,
value: 250,
unit: "ml",
measuredAt: new Date("2026-07-14T12:00:00Z"),
},
});
}
contract<string>("GET /api/custom-metrics", {
call: async () => {
const { GET } = await import("@/app/api/custom-metrics/route");
return call(GET as Handler, "/api/custom-metrics");
},
seed: seedCustomMetric,
read: (payload: { customMetrics: { name: string }[] }) =>
payload.customMetrics.map((m) => m.name),
ownerMarker: "owner-water",
delegateMarker: "delegate-water",
});
describe("GET /api/custom-metrics/[id] and its entries", () => {
it("reach the owner's metric and refuse once the grant ends", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedCustomMetric(owner.id, "owner-water");
await seedCustomMetric(delegate.id, "delegate-water");
const ownerMetric = await getPrismaClient().customMetric.findFirstOrThrow({
where: { userId: owner.id },
});
const delegateMetric =
await getPrismaClient().customMetric.findFirstOrThrow({
where: { userId: delegate.id },
});
const detail = await import("@/app/api/custom-metrics/[id]/route");
const entries = await import("@/app/api/custom-metrics/[id]/entries/route");
const { grant } = await switchInto(owner.id, delegate.id);
const mine = await call(
detail.GET as Handler,
`/api/custom-metrics/${ownerMetric.id}`,
{ id: ownerMetric.id },
);
expect(mine.status).toBe(200);
expect((await body(mine)).data).toMatchObject({ name: "owner-water" });
// The entries route establishes ownership on the METRIC first and only
// then queries by `customMetricId`. That hop now resolves against the
// owner, so the delegate's own metric is unreachable from in here.
const theirEntries = await call(
entries.GET as Handler,
`/api/custom-metrics/${delegateMetric.id}/entries`,
{ id: delegateMetric.id },
);
expect(theirEntries.status).toBe(404);
const ownerEntries = await call(
entries.GET as Handler,
`/api/custom-metrics/${ownerMetric.id}/entries`,
{ id: ownerMetric.id },
);
expect(ownerEntries.status).toBe(200);
const payload = (await body(ownerEntries)).data as {
entries: { value: number }[];
};
expect(payload.entries.map((e) => e.value)).toEqual([250]);
await revokeGrant(grant.id);
await expectDenied(
await call(
entries.GET as Handler,
`/api/custom-metrics/${ownerMetric.id}/entries`,
{ id: ownerMetric.id },
),
);
});
it("are unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
await seedCustomMetric(plain.id, "plain-water");
const metric = await getPrismaClient().customMetric.findFirstOrThrow({
where: { userId: plain.id },
});
await signIn(plain.id);
const { GET } = await import("@/app/api/custom-metrics/[id]/route");
const response = await call(
GET as Handler,
`/api/custom-metrics/${metric.id}`,
{ id: metric.id },
);
expect(response.status).toBe(200);
expect((await body(response)).data).toMatchObject({ name: "plain-water" });
});
});
async function seedPersonalRecord(userId: string, value: number) {
await getPrismaClient().personalRecord.create({
data: {
userId,
metricType: "WEIGHT",
direction: "MIN",
value,
unit: "kg",
achievedAt: new Date("2026-06-01T08:00:00Z"),
},
});
}
contract<number>("GET /api/personal-records", {
call: async () => {
const { GET } = await import("@/app/api/personal-records/route");
return call(GET as Handler, "/api/personal-records");
},
seed: seedPersonalRecord,
read: (payload: { value: number }[]) => payload.map((r) => r.value),
ownerMarker: 79.5,
delegateMarker: 61.5,
});