feat(concurrency): guard layout and about-me writes against a concurrent overwrite

Extract the dashboard-layout optimistic-concurrency mechanism (issue #581)
into a shared server helper (takeBaseToken plus guardedUserUpdate /
guardedRowUpdate) and a client helper (readUpdatedAtToken, withBaseToken,
isConflict). Retrofit the dashboard-widgets route onto the helper and apply the
same guard to the insights layout, medications layout, and coach about-me write
endpoints.

A request that omits the base token keeps the byte-identical unconditional
write, so older clients are unaffected. Document the contract in the OpenAPI
registry, including the dashboard-widgets path that was previously absent.
This commit is contained in:
Marc-André Bombeck committed 2026-07-24 16:26:58 +02:00
1 parent df4425babf
commit ab03bc96f1
27 files changed
+1669 -352

No files matched your search

+60 -9
View File
@@ -35,6 +35,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { takeBaseToken, invalidBaseTokenError } from "@/lib/optimistic-lock";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
@@ -110,12 +111,21 @@ export const PUT = apiHandler(async (req: Request) => {
return response;
}
const { data: body, error: jsonError } = await safeJson(req, {
const { data: rawBody, error: jsonError } = await safeJson(req, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = aboutMePutSchema.safeParse(body);
// v1.32.21 (R5a) — pull the optimistic-concurrency base token off the body
// BEFORE the Zod parse (issue #581 family). The token guards on
// `UserHealthProfile.updatedAt`, which — unlike the layout endpoints on the
// shared `User` row — moves only when THIS surface (or the `/adopt`
// sub-route) writes, so it is noise-free.
const taken = takeBaseToken(rawBody);
if ("invalid" in taken) return invalidBaseTokenError();
const base = taken.base;
const parsed = aboutMePutSchema.safeParse(taken.rest);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
@@ -165,12 +175,53 @@ export const PUT = apiHandler(async (req: Request) => {
: {}),
};
const row = await prisma.userHealthProfile.upsert({
where: { userId: user.id },
create: { userId: user.id, ...update },
update,
select: { updatedAt: true },
});
// v1.32.21 (R5a) — optimistic concurrency on `UserHealthProfile.updatedAt`.
// - no base token → today's upsert (backward-compat arm, byte-identical);
// - base present + row still carries it → conditional update, echo fresh;
// - base present + zero match → the row either vanished (deleted since the
// read → create wins, nothing to clobber) or advanced (real conflict →
// 409, no write).
let updatedAtIso: string;
if (base === undefined) {
const row = await prisma.userHealthProfile.upsert({
where: { userId: user.id },
create: { userId: user.id, ...update },
update,
select: { updatedAt: true },
});
updatedAtIso = row.updatedAt.toISOString();
} else {
const guarded = await prisma.userHealthProfile.updateMany({
where: { userId: user.id, updatedAt: base },
data: update,
});
if (guarded.count === 0) {
const existing = await prisma.userHealthProfile.findUnique({
where: { userId: user.id },
select: { updatedAt: true },
});
if (existing) {
annotate({
action: { name: "coach.about_me.conflict" },
meta: { base_updated_at: base.toISOString() },
});
return apiError("Self-context changed since it was loaded", 409, {
errorCode: "about_me_conflict",
});
}
const created = await prisma.userHealthProfile.create({
data: { userId: user.id, ...update },
select: { updatedAt: true },
});
updatedAtIso = created.updatedAt.toISOString();
} else {
const fresh = await prisma.userHealthProfile.findUnique({
where: { userId: user.id },
select: { updatedAt: true },
});
updatedAtIso = (fresh?.updatedAt ?? new Date()).toISOString();
}
}
// Read back the effective state (covers omitted fields) and derive
// the clarifying questions. An entirely empty self-context clears
@@ -222,7 +273,7 @@ export const PUT = apiHandler(async (req: Request) => {
allergies: ctx.allergies,
coachFocus: ctx.coachFocus,
pendingQuestions,
updatedAt: row.updatedAt.toISOString(),
updatedAt: updatedAtIso,
maxChars: ABOUT_ME_MAX_CHARS,
fieldMaxChars: ABOUT_ME_FIELD_MAX_CHARS,
});
+44 -57
View File
@@ -8,7 +8,6 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
apiError,
buildPayloadDiagnostic,
safeJson,
returnAllZodIssues,
@@ -16,6 +15,11 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma, toJson } from "@/lib/db";
import {
takeBaseToken,
guardedUserUpdate,
invalidBaseTokenError,
} from "@/lib/optimistic-lock";
import {
resolveDashboardLayout,
serializeDashboardLayout,
@@ -152,12 +156,10 @@ const layoutSchema = z.object({
.array(z.enum(HERO_RING_IDS))
.max(MAX_HERO_RING_ORDER)
.optional(),
// v1.32.16 (issue #581) — optimistic-concurrency base token. The client
// sends the ISO `updatedAt` it read the layout at; the write is then
// guarded on the stored row still carrying it (else 409, no write).
// Optional so older web builds and the native client keep the prior
// unconditional write. Not a layout field — stripped before serialize.
baseUpdatedAt: z.string().optional(),
// v1.32.16 (issue #581) — the optimistic-concurrency base token used to
// live here. v1.32.21 (R5a) moved it to the shared `takeBaseToken` helper,
// which strips it BEFORE this parse: the token is transport, not a layout
// field, and pulling it pre-Zod keeps the domain schema pure.
});
async function buildDashboardLayout(
@@ -193,11 +195,20 @@ export const GET = apiHandler(async () => {
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 256 * 1024,
});
if (jsonError) return jsonError;
// v1.32.21 (R5a) — pull the optimistic-concurrency base token off the body
// BEFORE anything else touches it (issue #581). A malformed token 422s
// without a write; an absent token takes the unconditional backward-compat
// arm inside `guardedUserUpdate` below.
const taken = takeBaseToken(rawBody);
if ("invalid" in taken) return invalidBaseTokenError();
const base = taken.base;
const body = taken.rest;
// v1.7.0 W1 — accept-and-ignore widget ids OUTSIDE the 27-id catalogue
// on write. The 27 ids (16 web + 11 iOS-only) all validate + persist;
// only a genuinely-unknown id (a retired tile, or a typo) is filtered
@@ -325,11 +336,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// `ChartOverlayPrefs` requires it; `coerceChartOverlayPrefsMap` inside the
// serializer fills the gap, so the assertion is safe here exactly as it was
// on the previous per-field cast.
// v1.32.16 (issue #581) — the concurrency token is a request-only field,
// not part of the layout; split it out before the serialize pipeline so
// it can never leak into the stored blob.
const { baseUpdatedAt, ...layoutData } = parsed.data;
let toSerialize = layoutData as Partial<DashboardLayout>;
// v1.32.16 (issue #581) — the concurrency token was already split off the
// body pre-Zod (`takeBaseToken`), so `parsed.data` is pure layout and can
// never leak the token into the stored blob.
let toSerialize = parsed.data as Partial<DashboardLayout>;
const incomingChartOverlayPrefs = toSerialize.chartOverlayPrefs;
const needsRangePointsPreserve =
incomingChartOverlayPrefs !== undefined &&
@@ -377,49 +387,26 @@ export const PUT = apiHandler(async (request: NextRequest) => {
}
const normalized = serializeDashboardLayout(toSerialize as DashboardLayout);
// v1.32.16 (issue #581) — optimistic concurrency. When the client sends
// the `baseUpdatedAt` it read the layout at, the write is CONDITIONAL on
// the stored row still carrying that token: `updateMany` matches zero
// rows once any other request has advanced `updatedAt`, so a Save that
// already committed can never be silently reverted by a later request
// (e.g. an instant hero-ring PUT) that started from the pre-Save
// snapshot. A stale base returns 409 and writes NOTHING. Clients that
// omit the token (older web builds, the native client) keep the prior
// unconditional write — backward-compatible, still preserve-when-absent.
let freshUpdatedAt: Date;
if (baseUpdatedAt !== undefined) {
const base = new Date(baseUpdatedAt);
if (Number.isNaN(base.getTime())) {
return apiError("Invalid base token", 422, {
errorCode: "invalid_base_updated_at",
});
}
const guarded = await prisma.user.updateMany({
where: { id: user.id, updatedAt: base },
data: { dashboardWidgetsJson: toJson(normalized) },
});
if (guarded.count === 0) {
annotate({
action: { name: "dashboard.widgets.conflict" },
meta: { base_updated_at: baseUpdatedAt },
});
return apiError("Dashboard layout changed since it was loaded", 409, {
errorCode: "dashboard_layout_conflict",
});
}
const fresh = await prisma.user.findUnique({
where: { id: user.id },
select: { updatedAt: true },
});
freshUpdatedAt = fresh?.updatedAt ?? new Date();
} else {
const updated = await prisma.user.update({
where: { id: user.id },
data: { dashboardWidgetsJson: toJson(normalized) },
select: { updatedAt: true },
});
freshUpdatedAt = updated?.updatedAt ?? new Date();
}
// v1.32.16 (issue #581) / v1.32.21 (R5a) — optimistic concurrency through
// the shared `guardedUserUpdate` helper. When the client sends the
// `baseUpdatedAt` it read the layout at, the write is CONDITIONAL on the
// stored row still carrying that token, so a Save that already committed
// can never be silently reverted by a later request (e.g. an instant
// hero-ring PUT) that started from the pre-Save snapshot. A stale base
// returns 409 and writes NOTHING. Clients that omit the token (older web
// builds, the native client) keep the prior unconditional write.
const guarded = await guardedUserUpdate({
userId: user.id,
base,
data: { dashboardWidgetsJson: toJson(normalized) },
conflict: {
action: "dashboard.widgets.conflict",
errorCode: "dashboard_layout_conflict",
message: "Dashboard layout changed since it was loaded",
},
});
if ("conflict" in guarded) return guarded.conflict;
const freshUpdatedAt = guarded.updatedAt;
annotate({
action: { name: "dashboard.widgets.update" },
@@ -433,7 +420,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// Echo the advanced token so the client can base its next write on it.
return apiSuccess({
...normalized,
updatedAt: freshUpdatedAt.toISOString(),
updatedAt: freshUpdatedAt,
} satisfies DashboardLayoutWithToken);
});
+44 -11
View File
@@ -23,13 +23,18 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma, toJson } from "@/lib/db";
import {
takeBaseToken,
guardedUserUpdate,
invalidBaseTokenError,
} from "@/lib/optimistic-lock";
import {
resolveInsightsLayout,
serializeInsightsLayout,
DEFAULT_INSIGHTS_LAYOUT,
ACCEPTED_INSIGHTS_TILE_IDS,
INSIGHTS_SECTION_IDS,
type InsightsLayout,
type InsightsLayoutWithToken,
} from "@/lib/insights-layout";
import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
@@ -95,12 +100,21 @@ const layoutSchema = z.object({
.optional(),
});
async function buildInsightsLayout(userId: string): Promise<InsightsLayout> {
async function buildInsightsLayout(
userId: string,
): Promise<InsightsLayoutWithToken> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { insightsLayoutJson: true },
// v1.32.21 (R5a) — `updatedAt` rides the cached layout so GET hands the
// client the optimistic-concurrency token alongside the layout it applies
// to. Cached together (and busted on every write) so the (layout, token)
// pair a client reads is always consistent — the widgets precedent.
select: { insightsLayoutJson: true, updatedAt: true },
});
return resolveInsightsLayout(row?.insightsLayoutJson);
return {
...resolveInsightsLayout(row?.insightsLayoutJson),
updatedAt: row?.updatedAt?.toISOString(),
};
}
export const GET = apiHandler(async () => {
@@ -110,7 +124,7 @@ export const GET = apiHandler(async () => {
// changes only when the user hits the Settings save button, which
// invalidates this cache via `invalidateUserInsightsLayout()`.
const layout = await cached(
caches.insightsLayout as ServerCache<InsightsLayout>,
caches.insightsLayout as ServerCache<InsightsLayoutWithToken>,
user.id,
() => buildInsightsLayout(user.id),
annotate,
@@ -121,11 +135,18 @@ export const GET = apiHandler(async () => {
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 256 * 1024,
});
if (jsonError) return jsonError;
// v1.32.21 (R5a) — pull the optimistic-concurrency base token off the body
// BEFORE the Zod parse so the domain schema stays pure (issue #581 family).
const taken = takeBaseToken(rawBody);
if ("invalid" in taken) return invalidBaseTokenError();
const base = taken.base;
const body = taken.rest;
const parsed = layoutSchema.safeParse(body);
if (!parsed.success) {
// Multi-issue 422 envelope matches the dashboard-widgets route so
@@ -179,12 +200,21 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const previous = await buildInsightsLayout(user.id);
const normalized = serializeInsightsLayout(parsed.data, previous);
await prisma.user.update({
where: { id: user.id },
data: {
insightsLayoutJson: toJson(normalized),
// v1.32.21 (R5a) — guard the write on the client's base token. A stale base
// 409s and writes NOTHING (so an interleaved edit-mode Save can't be
// reverted by a Settings pill-order Save that started from an older read,
// and vice versa); a tokenless request keeps the prior unconditional write.
const guarded = await guardedUserUpdate({
userId: user.id,
base,
data: { insightsLayoutJson: toJson(normalized) },
conflict: {
action: "insights.layout.conflict",
errorCode: "insights_layout_conflict",
message: "Insights layout changed since it was loaded",
},
});
if ("conflict" in guarded) return guarded.conflict;
annotate({
action: { name: "insights.layout.update" },
@@ -193,7 +223,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
invalidateUserInsightsLayout(user.id);
return apiSuccess(normalized);
return apiSuccess({
...normalized,
updatedAt: guarded.updatedAt,
} satisfies InsightsLayoutWithToken);
});
export const DELETE = apiHandler(async () => {
+43 -9
View File
@@ -21,6 +21,11 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma, toJson } from "@/lib/db";
import {
takeBaseToken,
guardedUserUpdate,
invalidBaseTokenError,
} from "@/lib/optimistic-lock";
import {
resolveMedicationListLayout,
serializeMedicationListLayout,
@@ -28,7 +33,7 @@ import {
MEDICATION_LIST_VIEWS,
MEDICATION_ORDER_ID_MAX_LENGTH,
MEDICATION_ORDER_MAX_ENTRIES,
type MedicationListLayout,
type MedicationListLayoutWithToken,
} from "@/lib/medication-list-layout";
import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
@@ -55,12 +60,18 @@ const layoutSchema = z.object({
async function buildMedicationListLayout(
userId: string,
): Promise<MedicationListLayout> {
): Promise<MedicationListLayoutWithToken> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { medicationListLayoutJson: true },
// v1.32.21 (R5a) — `updatedAt` rides the cached blob so GET hands the
// client the optimistic-concurrency token alongside the presentation it
// applies to (busted on every write, so the pair stays consistent).
select: { medicationListLayoutJson: true, updatedAt: true },
});
return resolveMedicationListLayout(row?.medicationListLayoutJson);
return {
...resolveMedicationListLayout(row?.medicationListLayoutJson),
updatedAt: row?.updatedAt?.toISOString(),
};
}
export const GET = apiHandler(async () => {
@@ -70,7 +81,7 @@ export const GET = apiHandler(async () => {
// caches; the blob changes only on a view toggle or an order save,
// which invalidates via `invalidateUserMedicationListLayout()`.
const layout = await cached(
caches.medicationListLayout as ServerCache<MedicationListLayout>,
caches.medicationListLayout as ServerCache<MedicationListLayoutWithToken>,
user.id,
() => buildMedicationListLayout(user.id),
annotate,
@@ -81,11 +92,20 @@ export const GET = apiHandler(async () => {
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
// v1.32.21 (R5a) — pull the optimistic-concurrency base token off the body
// BEFORE the Zod parse so the domain schema stays pure (issue #581 family).
// BOTH writers (view toggle, order save) route through here, so the token
// guards either against a stale interleave of the other.
const taken = takeBaseToken(rawBody);
if ("invalid" in taken) return invalidBaseTokenError();
const base = taken.base;
const body = taken.rest;
const parsed = layoutSchema.safeParse(body);
if (!parsed.success) {
// Multi-issue 422 envelope matches the dashboard-widgets / insights
@@ -144,10 +164,21 @@ export const PUT = apiHandler(async (request: NextRequest) => {
order: mergedOrder,
});
await prisma.user.update({
where: { id: user.id },
// v1.32.21 (R5a) — guard the write on the client's base token. A stale base
// 409s and writes NOTHING, so a view toggle can't resurrect an order the
// reorder Save just committed (or vice versa); a tokenless request keeps the
// prior unconditional write.
const guarded = await guardedUserUpdate({
userId: user.id,
base,
data: { medicationListLayoutJson: toJson(normalized) },
conflict: {
action: "medication.layout.conflict",
errorCode: "medication_layout_conflict",
message: "Medications layout changed since it was loaded",
},
});
if ("conflict" in guarded) return guarded.conflict;
annotate({
action: { name: "medication.layout.update" },
@@ -156,7 +187,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
invalidateUserMedicationListLayout(user.id);
return apiSuccess(normalized);
return apiSuccess({
...normalized,
updatedAt: guarded.updatedAt,
} satisfies MedicationListLayoutWithToken);
});
export const DELETE = apiHandler(async () => {