Give a grant a section set and a third level

A grant has meant one thing since it shipped: the whole record, to read or to
add to. This lays the words for the two shapes the rest of the sharing work
needs — part of a record, and management of one — while changing what no
existing grant does.

`ShareDomain` is a closed set of eight sections in `src/lib/sharing/scope.ts`,
clustered from the delegable read surfaces rather than invented, plus the
distinguished `record` for the routes that read across them. It never converts
to a `ModuleKey` and never imports one: two masks, two questions, no shared
spelling, because the day they share a name is the day somebody makes one
imply the other, and a module the owner enabled is not a section the owner
shared.

`account_grants.scope_json` is NULL on every row that exists, and NULL is the
entire record — the answer those rows were consented as, so no backfill and no
re-consent. A non-NULL value is read through one fail-closed normaliser: a
blob it cannot parse opens nothing, including a section the blob happened to
name. That is the deliberate inverse of the module preference normaliser,
which fails open on purpose; both do the safe thing with a value nobody can
read, and the safe thing differs because one answers an authorization question
and the other a presentation question.

`MANAGE` joins the level enum, ordered above WRITE so a write grant can never
satisfy it, and carries no scope at all: management of part of a record
promises a boundary that cannot survive an edit, so the invite path refuses to
write one.

`requireRecordAuth` takes the section as a second required argument. The
requirement is the lever — a delegable route without a classification does not
typecheck, so the set of classified routes cannot fall behind the set of
delegable ones. All fifty-seven modules carry theirs. The structural guard
freezes each against the production vocabulary, fails when a module's own arms
disagree, and pins a non-zero count of resolved sections so a matcher that
stopped reading the argument fails instead of agreeing with everything.

`requireGuardianAuth` is exported and reaches nothing yet. It gates on the
managed-profile marker rather than on the grant, so an adult who granted
management of their own record cannot arrive at that record's settings by any
argument about what MANAGE means: widening MANAGE would widen the record
resolver and would not touch this one. Its frozen list lands empty, with the
declaration site as the positive control that keeps "nothing reaches it" a
measurement rather than a renamed symbol.

`users.managed_profile_at` says since when a record is one somebody else runs.
A timestamp and not a boolean, for a reason the schema has to keep possible:
clearing it is the handover, and the grants and the activity trail span it
untouched.
This commit is contained in:
Marc-André Bombeck committed 2026-08-04 17:11:40 +02:00
1 parent 03877a0971
commit da3cabb55f
77 files changed
+2305 -266

No files matched your search

+7
View File
@@ -142,6 +142,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
grantorId: user.id,
granteeId: invitee.id,
access,
// The entire record, which is what this endpoint has always offered and
// what every stored grant means. Named rather than defaulted, because
// the domain module takes the scope as a required argument for the same
// reason it takes the level as one: a scope nobody chose is not a scope.
// The consent surface that lets an owner narrow it lands beside this
// line, in this release.
scope: null,
expiresAt: expiresAt ? new Date(expiresAt) : null,
});
+1 -1
View File
@@ -31,7 +31,7 @@ type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "profile");
const { id } = await params;
const row = await prisma.allergy.findUnique({ where: { id } });
+1 -1
View File
@@ -29,7 +29,7 @@ import {
import { toAllergyDTO } from "@/lib/records/dto";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "profile");
const params = new URL(request.url).searchParams;
const parsed = allergyListQuerySchema.safeParse({
+1 -1
View File
@@ -19,7 +19,7 @@ import {
import { healthProfileFactWriteSchema } from "@/lib/validations/health-profile-facts";
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "profile");
const payload = await readHealthProfileFacts(user.id);
annotate({
action: { name: "anamnesis.facts.get" },
+1 -1
View File
@@ -64,7 +64,7 @@ function serialiseBiomarker(row: {
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "labs");
const { id } = await params;
const row = await prisma.biomarker.findFirst({ where: { id } });
+2 -2
View File
@@ -71,7 +71,7 @@ function serialiseBiomarker(row: {
}
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "labs");
// Visible markers first, then hidden — the manager renders them in two
// sections and the lab-entry pickers drop the hidden tail client-side.
@@ -95,7 +95,7 @@ async function postBiomarker(request: NextRequest) {
// duplicate pre-check and the `@@unique([userId, name])` backstop both scope
// to the same resolved id, so a delegate adding a marker the owner already
// tracks gets the ordinary 409 rather than a second copy.
const { user } = await requireRecordAuth("write");
const { user } = await requireRecordAuth("write", "labs");
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
+1 -1
View File
@@ -79,7 +79,7 @@ export const GET = apiHandler(async (req: Request) => {
//
// The module gate below resolves against the record, so the surface exists
// for a delegate only where the OWNER switched the Coach on.
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "record");
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
@@ -35,7 +35,7 @@ type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "measurements");
const { id } = await params;
const metric = await prisma.customMetric.findFirst({
+1 -1
View File
@@ -38,7 +38,7 @@ function effectiveBound(
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "measurements");
const { id } = await params;
const row = await prisma.customMetric.findFirst({
+1 -1
View File
@@ -29,7 +29,7 @@ import { createCustomMetricSchema } from "@/lib/validations/custom-metrics";
*/
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "measurements");
const rows = await prisma.customMetric.findMany({
where: { userId: user.id, deletedAt: null },
+1 -1
View File
@@ -29,7 +29,7 @@ const DEFAULT_LIMIT = 24;
const REGULAR_MAD_THRESHOLD = 2;
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "cycle");
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
+1 -1
View File
@@ -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 requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "cycle");
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
+1 -1
View File
@@ -14,7 +14,7 @@ import { toCycleProfileDTO } from "@/lib/cycle/dto";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "cycle");
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
+1 -1
View File
@@ -41,7 +41,7 @@ import {
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "cycle");
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
+1 -1
View File
@@ -31,7 +31,7 @@ export const GET = apiHandler(async () => {
// matters — a delegate gets the hero only where the owner switched insights
// on, never where the delegate did. Re-examine with the snapshot when
// per-module scope lands; a digest is a summary of several modules at once.
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "record");
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
+1 -1
View File
@@ -54,7 +54,7 @@ export const GET = apiHandler(async () => {
//
// The cache cell keys on the resolved id, so the owner's snapshot lands in
// the owner's cell and a delegated read neither reads nor poisons their own.
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "record");
annotate({ action: { name: "dashboard.snapshot" } });
const timings: Record<string, number> = {};
+1 -1
View File
@@ -196,7 +196,7 @@ export const GET = apiHandler(async () => {
// matter, which comparison baseline reads correctly for it. A delegate
// opening somebody's dashboard should see it arranged the way its owner
// arranged it.
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "record");
// 5-minute TTL per blueprint §5; the layout changes only when the user
// hits the Settings → Dashboard save button, which invalidates this
@@ -117,7 +117,7 @@ function safeDownloadName(
*/
export const GET = apiHandler(
async (_request: Request, { params }: RouteParams) => {
const { user, actor } = await requireRecordAuth("read");
const { user, actor } = await requireRecordAuth("read", "documents");
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
+1 -1
View File
@@ -76,7 +76,7 @@ async function enforceWriteRateLimit(userId: string): Promise<Response | null> {
*/
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "documents");
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
@@ -52,7 +52,7 @@ const THUMBNAIL_READ_WINDOW_MS = 60 * 60 * 1000;
*/
export const GET = apiHandler(
async (_request: Request, { params }: RouteParams) => {
const { user, actor } = await requireRecordAuth("read");
const { user, actor } = await requireRecordAuth("read", "documents");
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
+1 -1
View File
@@ -458,7 +458,7 @@ export const POST = apiHandler(withIdempotency<[Request]>(postUpload));
* declare separately rather than share one line.
*/
export const GET = apiHandler(async (request: Request) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "documents");
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
+1 -1
View File
@@ -34,7 +34,7 @@ export const dynamic = "force-dynamic";
* No write arm exists on this route.
*/
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "documents");
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
+1 -1
View File
@@ -29,7 +29,7 @@ type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "profile");
const { id } = await params;
const row = await prisma.familyHistoryEntry.findUnique({ where: { id } });
+1 -1
View File
@@ -30,7 +30,7 @@ import {
import { toFamilyHistoryEntryDTO } from "@/lib/records/dto";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "profile");
const params = new URL(request.url).searchParams;
const parsed = familyHistoryListQuerySchema.safeParse({
@@ -47,9 +47,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
//
// Read-only in fact as well as in declaration: v1.35.3 moved the unlock
// INSERT onto the sweep job, so there is no write for a read grant to have
// to refuse. `requireRecordAuth("read")` would refuse a non-safe method
// to refuse. `requireRecordAuth("read", "record")` would refuse a non-safe method
// anyway; this file exports no other verb.
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "record");
// v1.18.0 — when the account has the achievements module turned off the
// whole gamification surface disappears: no badge evaluation, no unlock
@@ -55,7 +55,7 @@ async function loadOwnedEpisode(
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "illness");
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
+1 -1
View File
@@ -30,7 +30,7 @@ type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "illness");
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
+2 -2
View File
@@ -33,7 +33,7 @@ import {
import { toIllnessEpisodeDTO } from "@/lib/illness/dto";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "illness");
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
@@ -78,7 +78,7 @@ async function postEpisode(request: NextRequest): Promise<Response> {
// the caller: whether illness tracking exists is a property of the record, so
// a delegate who runs the module on their own account does not thereby gain
// an episode surface inside somebody else's.
const { user } = await requireRecordAuth("write");
const { user } = await requireRecordAuth("write", "illness");
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
@@ -43,7 +43,7 @@ export const GET = apiHandler(async () => {
// switch and that is not in tension with this: chat spends the owner's AI
// budget and writes into their conversation, and reading whether the thread
// has something unopened does neither.
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "record");
// Operator-level flag — an `AppSettings` singleton, unaffected by whose
// record is open.
await requireAssistantSurface("coach");
+1 -1
View File
@@ -65,7 +65,7 @@ function toResolved(
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "labs");
const { id } = await params;
const row = await prisma.labResult.findFirst({
+2 -2
View File
@@ -70,7 +70,7 @@ function toResolved(
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "labs");
const params = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listLabResultsSchema.safeParse(params);
@@ -143,7 +143,7 @@ async function postLabResult(request: NextRequest) {
// it mints it under `user.id`: the marker joins the record's catalogue, not
// the caller's, which is the only reading of "add a result to your record"
// that leaves the owner with a usable catalogue afterwards.
const { user } = await requireRecordAuth("write");
const { user } = await requireRecordAuth("write", "labs");
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
@@ -41,7 +41,7 @@ async function resolveTimezone(userId: string): Promise<string> {
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "measurements");
const { id } = await params;
const reminder = await prisma.measurementReminder.findFirst({
+1 -1
View File
@@ -36,7 +36,7 @@ async function resolveTimezone(userId: string): Promise<string> {
}
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "measurements");
const reminders = await prisma.measurementReminder.findMany({
where: { userId: user.id, deletedAt: null },
+1 -1
View File
@@ -27,7 +27,7 @@ type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "measurements");
const { id } = await params;
+2 -2
View File
@@ -58,7 +58,7 @@ import type {
import { Prisma } from "@/generated/prisma/client";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "measurements");
const params = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listMeasurementsSchema.safeParse(params);
@@ -755,7 +755,7 @@ async function postMeasurement(request: NextRequest) {
// who may or may not be that person. Nothing else in this handler reads the
// caller: the safety-floor check, the reminder satisfaction, the rollup
// recompute and the cache eviction are all statements about the record.
const { user } = await requireRecordAuth("write");
const { user } = await requireRecordAuth("write", "measurements");
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
@@ -33,7 +33,7 @@ import {
} from "@/lib/validations/series-batch";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "measurements");
const params = Object.fromEntries(request.nextUrl.searchParams);
const parsed = seriesBatchQuerySchema.safeParse(params);
+1 -1
View File
@@ -160,7 +160,7 @@ function stdDev(values: number[]): number {
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "measurements");
const rawQuery = Object.fromEntries(request.nextUrl.searchParams);
const parsed = querySchema.safeParse(rawQuery);
@@ -42,7 +42,7 @@ const querySchema = z.object({
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "medications");
const { id } = await params;
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
+5 -2
View File
@@ -51,7 +51,10 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
// v1.36.x — a delegated write, the per-medication form of the same verb.
// `user` is the record the dose belongs to; `actor` is whoever pressed the
// button, and is used for nothing but the owner's notification below.
const { user, actor, authMethod } = await requireRecordAuth("write");
const { user, actor, authMethod } = await requireRecordAuth(
"write",
"medications",
);
// v1.36.1 follow-up — the sibling route `POST /api/medications/intake`
// refuses a delegate changing a dose the owner already recorded. This route
@@ -588,7 +591,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "medications");
const { id } = await params;
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
@@ -44,7 +44,7 @@ const POST_WINDOW_MS = 60_000;
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "medications");
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
@@ -10,7 +10,7 @@ type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "medications");
const { id } = await params;
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
+1 -1
View File
@@ -76,7 +76,7 @@ function reconcileScheduleWindow(
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "medications");
const { id } = await params;
const medication = await prisma.medication.findUnique({
@@ -73,7 +73,7 @@ function summariseEntries(payload: unknown): Array<{
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "medications");
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
@@ -46,7 +46,7 @@ const POST_WINDOW_MS = 60_000;
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "medications");
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
@@ -100,7 +100,7 @@ export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
// v1.36.x — a delegated write. `user` is the record the entry lands under;
// `actor` is whoever is typing, and the two differ only under a switch.
const { user, actor } = await requireRecordAuth("write");
const { user, actor } = await requireRecordAuth("write", "medications");
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
+1 -1
View File
@@ -26,7 +26,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
* detail page keeps the per-id route for the grid.
*/
export const GET = apiHandler(async () => {
const { user, actor } = await requireRecordAuth("read");
const { user, actor } = await requireRecordAuth("read", "medications");
// 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:
+2 -2
View File
@@ -85,7 +85,7 @@ const updateSchema = z.object({
});
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "medications");
const parsed = querySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams),
@@ -226,7 +226,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// address the delegate, and none of them should — the delegate's feedback is
// the response they are already awaiting. What the owner gets instead is the
// notification at the end of this handler.
const { user, actor } = await requireRecordAuth("write");
const { user, actor } = await requireRecordAuth("write", "medications");
const { data: body, error } = await safeJson(request, {
maxBytes: 64 * 1024,
+1 -1
View File
@@ -84,7 +84,7 @@ export const GET = apiHandler(async () => {
// owner's list would fall back to default order — a preference that cannot
// apply is not a preference, it is noise. `/medications` is a shared
// destination, so this is a list a delegate really does see.
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "medications");
// 5-minute TTL matches the dashboard-widgets / insights-layout caches; the
// blob changes only on a view toggle or an order save, which invalidates via
+2 -2
View File
@@ -33,7 +33,7 @@ import { NextRequest } from "next/server";
const APPLE_HEALTH_MIRROR_CAP = 30;
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "medications");
// Cache the list shape on userId. The 60 s fresh TTL bounds the
// cross-midnight staleness window for `todayEventCount` and the
@@ -98,7 +98,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// it afterwards — `PUT /api/medications/[id]` and the DELETE beside it stay
// on `requireAuth()` and refuse under a switch. Adding is help; rewriting
// the owner's cabinet is not.
const { user } = await requireRecordAuth("write");
const { user } = await requireRecordAuth("write", "medications");
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
@@ -99,7 +99,7 @@ function respondExisting(row: Parameters<typeof shapeRow>[0]): Response {
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "mind");
// 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).
+1 -1
View File
@@ -36,7 +36,7 @@ function parseTags(tags: string | null): string[] {
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "mind");
const { id } = await params;
+1 -1
View File
@@ -41,7 +41,7 @@ function parseTags(tags: string | null): string[] {
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "mind");
const params = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listMoodEntriesSchema.safeParse(params);
+1 -1
View File
@@ -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 requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "measurements");
const gate = await requireModuleEnabled(user.id, "nutrients");
if (!gate.enabled) return gate.response;
+1 -1
View File
@@ -92,7 +92,7 @@ function pickTopSkipReason(byReason: Record<string, number>): string | null {
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "measurements");
const gate = await requireModuleEnabled(user.id, "nutrients");
if (!gate.enabled) return gate.response;
+1 -1
View File
@@ -36,7 +36,7 @@ const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 500;
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "measurements");
annotate({ action: { name: "personalRecords.list" } });
const { searchParams } = new URL(request.url);
@@ -24,7 +24,7 @@ export const GET = apiHandler(async () => {
//
// No write arm exists, so there is nothing to split: a delegate cannot move
// the owner's threshold, only read the one the owner set.
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "medications");
// Operator-level singleton (`lateMinutes` / `missedMinutes`) — the same for
// every account on the deployment, so the switch does not touch it.
+1 -1
View File
@@ -105,7 +105,7 @@ function serializeSession(s: SleepSession) {
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "measurements");
// Per-domain gate: the hypnogram read serves only the sleep module's
// view surfaces, so it gates on the sleep module. Disabled ⇒ 403.
+1 -1
View File
@@ -26,7 +26,7 @@ import { buildSleepRhythm } from "@/lib/insights/derived/sleep-rhythm";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "measurements");
// 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.
+1 -1
View File
@@ -70,7 +70,7 @@ const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 200;
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read");
const { user } = await requireRecordAuth("read", "measurements");
annotate({ action: { name: "workouts.list" } });
// v1.18.0 B1 — the workouts surface is a toggleable module. A disabled