From c1052c4f176480f90c65939115f755466c3b59fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 11:43:10 +0200 Subject: [PATCH] refactor(dashboard): move the widgets layout logic out of its route The route keeps authentication, the body, the base token, the layout schema (the inline-schema inventory reads it there), the conflict mapping and the responses. The rest moves verbatim into lib/dashboard/widgets/, one module per stage: the layout read and the availability probe for GET, the unknown-id drop, the refusal report and the stored-field merge for PUT, and the locked reset for DELETE. The route is 317 lines, down from 513; its longest function is 85 lines, down from 199. The parity walk recorded before the move (dashboard-widgets.parity) is unchanged. The tombstone-read guard now also scopes lib/dashboard/widgets/, so the moved HRV probes stay policed. --- src/__tests__/tombstone-read-guard.test.ts | 3 + src/app/api/dashboard/widgets/route.ts | 244 ++---------------- src/lib/dashboard/widgets/merge-stored.ts | 72 ++++++ src/lib/dashboard/widgets/read-layout.ts | 25 ++ src/lib/dashboard/widgets/reset-layout.ts | 43 +++ .../dashboard/widgets/unavailable-widgets.ts | 44 ++++ src/lib/dashboard/widgets/unknown-ids.ts | 48 ++++ .../dashboard/widgets/validation-report.ts | 35 +++ 8 files changed, 294 insertions(+), 220 deletions(-) create mode 100644 src/lib/dashboard/widgets/merge-stored.ts create mode 100644 src/lib/dashboard/widgets/read-layout.ts create mode 100644 src/lib/dashboard/widgets/reset-layout.ts create mode 100644 src/lib/dashboard/widgets/unavailable-widgets.ts create mode 100644 src/lib/dashboard/widgets/unknown-ids.ts create mode 100644 src/lib/dashboard/widgets/validation-report.ts diff --git a/src/__tests__/tombstone-read-guard.test.ts b/src/__tests__/tombstone-read-guard.test.ts index 58895e9a0..ba32b1ca0 100644 --- a/src/__tests__/tombstone-read-guard.test.ts +++ b/src/__tests__/tombstone-read-guard.test.ts @@ -38,6 +38,9 @@ const SCOPES = [ "app/api/insights/", "app/api/analytics/", "app/api/dashboard/", + // Request-handler logic moved out of its route into a service keeps the + // route's scope. + "lib/dashboard/widgets/", ]; /** `model@file` → why this read includes deleted rows. */ diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 3c394cebf..e4167a49a 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -6,17 +6,9 @@ * DELETE resets web-controlled choices and preserves the other preferences. */ import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; -import { - apiSuccess, - buildPayloadDiagnostic, - safeJson, - returnAllZodIssues, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiSuccess, safeJson, returnAllZodIssues } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; -import { isModuleEnabled } from "@/lib/modules/gate"; -import { unavailableWidgetIds } from "@/lib/dashboard/widget-modules"; -import { prisma, toJson } from "@/lib/db"; +import { toJson } from "@/lib/db"; import { takeBaseToken, guardedUserUpdate, @@ -24,8 +16,6 @@ import { } from "@/lib/optimistic-lock"; import { resolveDashboardLayout, - serializeDashboardLayout, - DEFAULT_DASHBOARD_LAYOUT, DASHBOARD_WIDGET_CATALOGUE_IDS, COMPARISON_BASELINES, CHART_OVERLAY_KEYS, @@ -34,16 +24,19 @@ import { HERO_RING_IDS, MAX_HERO_RING_ORDER, HERO_PRIMARY_CONTENTS, - layoutNeedsPreserveRead, - mergePreservedLayoutFields, type DashboardLayout, type DashboardLayoutWithToken, } from "@/lib/dashboard-layout"; +import { buildDashboardLayout } from "@/lib/dashboard/widgets/read-layout"; +import { resolveUnavailableWidgetIds } from "@/lib/dashboard/widgets/unavailable-widgets"; +import { dropUnknownWidgetIds } from "@/lib/dashboard/widgets/unknown-ids"; +import { reportLayoutValidationFailure } from "@/lib/dashboard/widgets/validation-report"; +import { layoutToStore } from "@/lib/dashboard/widgets/merge-stored"; +import { resetDashboardLayout } from "@/lib/dashboard/widgets/reset-layout"; import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; import { z } from "zod/v4"; import { invalidateUserDashboardWidgets } from "@/lib/cache/invalidate"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; -import { redactForExcerpt } from "@/lib/observability/redact-payload"; import type { NextRequest } from "next/server"; // Single source of truth — every widget id the layout accepts. The @@ -179,21 +172,6 @@ const layoutSchema = z.object({ // field, and pulling it pre-Zod keeps the domain schema pure. }); -async function buildDashboardLayout( - userId: string, -): Promise { - const row = await prisma.user.findUnique({ - where: { id: userId }, - // v1.32.16 (issue #581) — `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. - select: { dashboardWidgetsJson: true, updatedAt: true }, - }); - const layout = resolveDashboardLayout(row?.dashboardWidgetsJson); - return { ...layout, updatedAt: row?.updatedAt?.toISOString() }; -} - export const GET = apiHandler(async () => { // Read: the RECORD's layout. Write: refused — see the PUT and DELETE below. // @@ -224,42 +202,8 @@ export const GET = apiHandler(async () => { annotate, ); - // Which toggles cannot do anything for this account. Resolved here, on - // the server, so the Settings screen never has to fetch dashboard data - // to find out whether a switch it is about to draw is dead. - // - // Deliberately OUTSIDE the cached layout above: the layout cell is keyed - // on the user and holds their arrangement, which does not change when a - // module is toggled. Folding a module-dependent answer into it would - // serve a stale one for the rest of the 5-minute TTL — the switch would - // linger for minutes after Recovery went off, which is the confusion this - // is removing. - // - // The HRV probes are index-only existence checks - // (`@@index([userId, type, measuredAt])`), not a summaries read; it runs - // only when Recovery is off, so the common account pays nothing. - // One flag, read the way this codebase reads one flag — not the whole - // module map. `hrv` is the only conditionally-dead widget and `recovery` - // is the only module that decides it. - const recoveryEnabled = await isModuleEnabled(user.id, "recovery"); - let hasSdnn = true; - let hasRmssd = true; - if (!recoveryEnabled) { - const [sdnnRow, rmssdRow] = await Promise.all( - (["HEART_RATE_VARIABILITY", "HRV_RMSSD"] as const).map((type) => - prisma.measurement.findFirst({ - where: { userId: user.id, type, deletedAt: null }, - select: { id: true }, - }), - ), - ); - hasSdnn = sdnnRow !== null; - hasRmssd = rmssdRow !== null; - } - const unavailable = unavailableWidgetIds( - { recovery: recoveryEnabled }, - { hasSdnn, hasRmssd }, - ); + // Outside the cached layout on purpose; see `resolveUnavailableWidgetIds`. + const unavailable = await resolveUnavailableWidgetIds(user.id); return apiSuccess( unavailable.length > 0 ? { ...layout, unavailableWidgetIds: unavailable } @@ -295,76 +239,13 @@ export const PUT = apiHandler(async (request: NextRequest) => { const base = taken.base; const body = taken.rest; - // v1.7.0 — 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 - // out here BEFORE Zod so it can't 422 the whole blob. The strict enum - // still validates the surviving ids, so a malformed entry (non-string - // id, missing `order`) still 422s. An id outside the catalogue - // silently vanishes — acceptable, and greppable via the annotation. - const knownWidgetIds = new Set(DASHBOARD_WIDGET_CATALOGUE_IDS); - if ( - body && - typeof body === "object" && - Array.isArray((body as { widgets?: unknown }).widgets) - ) { - const widgetsBody = body as { - widgets: Array<{ id?: unknown }>; - }; - const droppedIds = widgetsBody.widgets - .map((w) => w?.id) - .filter( - (id): id is string => typeof id === "string" && !knownWidgetIds.has(id), - ); - if (droppedIds.length > 0) { - widgetsBody.widgets = widgetsBody.widgets.filter( - (w) => !(typeof w?.id === "string" && !knownWidgetIds.has(w.id)), - ); - // v1.7.0 — the unknown-id filter runs over the FULL incoming - // `widgets` array BEFORE Zod's `.max(20)` applies, so `droppedIds` - // is bounded only by the request body limit. Cap the logged array - // (keep the full `dropped_count`) so a single large request can't - // push thousands of strings into one wide-event line. - annotate({ - action: { name: "dashboard.widgets.unknown-id-dropped" }, - meta: { - dropped_ids: droppedIds.slice(0, 20), - dropped_count: droppedIds.length, - }, - }); - } - } + // Ids outside the catalogue are dropped before Zod (a retired tile must + // not 422 the whole blob). + dropUnknownWidgetIds(body); const parsed = layoutSchema.safeParse(body); if (!parsed.success) { - // v1.4.42 — the legacy `issues[0].message` envelope dropped - // every issue past the first. iOS contract debugging hit one - // round-trip per wrong field; the new helper surfaces every - // issue under `details.issues`. We additionally emit an - // append-only audit-ledger row so the operator can grep - // `/api/admin/audit` for these without combing the iOS dev - // console. - const issues = sanitiseZodIssues(parsed.error.issues); - // v1.4.48 H-iOS-1 — surface the iOS-sent payload shape alongside - // the Zod rejection so a single wide-event line carries enough - // detail to diagnose serialiser drift in `HealthLog-iOS`. We log - // ONLY the top-level keys plus a hard 256-char JSON excerpt — never - // the full body — so PII / token-like fields cannot leak. The - // diagnostic shape is built by the shared `buildPayloadDiagnostic` - // helper (v1.4.49) so the widget + series routes can't drift; the - // body is routed through `redactForExcerpt` first so any - // future field matching the denylist (password / token / secret / - // apiKey / authorization / csrfState / nonce) lands as the literal - // `"[redacted]"` instead of its raw value. - const payloadDiagnostic = buildPayloadDiagnostic(redactForExcerpt(body)); - annotate({ - action: { name: "dashboard.widgets.validation-failed" }, - meta: { - issue_count: issues.length, - ...payloadDiagnostic, - zod_issues: issues, - }, - }); + reportLayoutValidationFailure(body, parsed.error); // No audit row: this write resolves the caller (`requireAuth`), so the // refusal can only ever be the caller's own, which is telemetry and // lives on the wide event above. Only a delegate's refused attempt is @@ -372,66 +253,15 @@ export const PUT = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } - // Preserve every omitted client-owned field from the stored layout instead - // of clamping it to a default. The disposition map is exhaustive over - // `DashboardLayout`, so adding a field requires an explicit merge decision. - // One stored-layout read covers every fallback. The read is skipped only - // when the client supplied every preserved field and every supplied chart - // preference has rangePoints. - // - // The Zod shape leaves per-chart `comparisonBaseline` optional while - // `ChartOverlayPrefs` requires it. `coerceChartOverlayPrefsMap` inside the - // serializer fills that gap, so the assertion below is safe. - // 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; - const incomingChartOverlayPrefs = toSerialize.chartOverlayPrefs; - const needsRangePointsPreserve = - incomingChartOverlayPrefs !== undefined && - CHART_OVERLAY_KEYS.some((chartKey) => { - const prefs = incomingChartOverlayPrefs[chartKey]; - return prefs !== undefined && prefs.rangePoints === undefined; - }); - const needsPreserveRead = - layoutNeedsPreserveRead(toSerialize) || needsRangePointsPreserve; - if (needsPreserveRead) { - const existing = await prisma.user.findUnique({ - where: { id: user.id }, - select: { dashboardWidgetsJson: true }, - }); - const existingLayout = resolveDashboardLayout( - existing?.dashboardWidgetsJson, - ); - toSerialize = mergePreservedLayoutFields(toSerialize, existingLayout); - - if (needsRangePointsPreserve && toSerialize.chartOverlayPrefs) { - let mergedChartOverlayPrefs = toSerialize.chartOverlayPrefs; - for (const chartKey of CHART_OVERLAY_KEYS) { - const incomingPrefs = toSerialize.chartOverlayPrefs[chartKey]; - const storedRangePoints = - existingLayout.chartOverlayPrefs?.[chartKey]?.rangePoints; - if ( - incomingPrefs !== undefined && - incomingPrefs.rangePoints === undefined && - storedRangePoints !== undefined - ) { - if (mergedChartOverlayPrefs === toSerialize.chartOverlayPrefs) { - mergedChartOverlayPrefs = { ...toSerialize.chartOverlayPrefs }; - } - mergedChartOverlayPrefs[chartKey] = { - ...incomingPrefs, - rangePoints: storedRangePoints, - }; - } - } - toSerialize = { - ...toSerialize, - chartOverlayPrefs: mergedChartOverlayPrefs, - }; - } - } - const normalized = serializeDashboardLayout(toSerialize as DashboardLayout); + // Omitted fields are carried forward from the stored layout. The Zod shape + // leaves per-chart `comparisonBaseline` optional while `ChartOverlayPrefs` + // requires it; `coerceChartOverlayPrefsMap` inside the serializer fills + // that gap, so the assertion is safe. The concurrency token was split off + // pre-Zod, so `parsed.data` can never leak it into the stored blob. + const normalized = await layoutToStore( + user.id, + parsed.data as Partial, + ); // Optimistic concurrency through the shared `guardedUserUpdate` helper. // When a client sends the `baseUpdatedAt` token it read with the layout, the @@ -472,33 +302,7 @@ export const DELETE = apiHandler(async () => { // dashboard to defaults is the loudest possible version of redecorating it. const { user } = await requireAuth(); - // Under a row lock rather than Serializable isolation: a reset racing a - // chart-overlay toggle on the same account waits for it instead of - // failing with a serialization error (a 500). - const { normalized, updatedAt } = await prisma.$transaction(async (tx) => { - await tx.$queryRaw`SELECT id FROM users WHERE id = ${user.id} FOR UPDATE`; - const existing = await tx.user.findUnique({ - where: { id: user.id }, - select: { dashboardWidgetsJson: true }, - }); - const existingLayout = resolveDashboardLayout( - existing?.dashboardWidgetsJson, - ); - const normalized = serializeDashboardLayout({ - ...existingLayout, - widgets: DEFAULT_DASHBOARD_LAYOUT.widgets, - enabledHeroItemKinds: DEFAULT_DASHBOARD_LAYOUT.enabledHeroItemKinds, - // A reset brings a hidden top card back. - todayCardVisible: DEFAULT_DASHBOARD_LAYOUT.todayCardVisible, - }); - - const updated = await tx.user.update({ - where: { id: user.id }, - data: { dashboardWidgetsJson: toJson(normalized) }, - select: { updatedAt: true }, - }); - return { normalized, updatedAt: updated.updatedAt.toISOString() }; - }); + const { normalized, updatedAt } = await resetDashboardLayout(user.id); annotate({ action: { name: "dashboard.widgets.reset" } }); diff --git a/src/lib/dashboard/widgets/merge-stored.ts b/src/lib/dashboard/widgets/merge-stored.ts new file mode 100644 index 000000000..2ed40aeff --- /dev/null +++ b/src/lib/dashboard/widgets/merge-stored.ts @@ -0,0 +1,72 @@ +/** + * The layout a dashboard-layout save stores: what the client sent, with + * every omitted client-owned field carried forward from the stored layout + * instead of clamped to a default. + * + * The disposition map is exhaustive over `DashboardLayout`, so adding a + * field requires an explicit merge decision. One stored-layout read covers + * every fallback. The read is skipped only when the client supplied every + * preserved field and every supplied chart preference has rangePoints. + */ +import { prisma } from "@/lib/db"; +import { + CHART_OVERLAY_KEYS, + layoutNeedsPreserveRead, + mergePreservedLayoutFields, + resolveDashboardLayout, + serializeDashboardLayout, + type DashboardLayout, +} from "@/lib/dashboard-layout"; + +export async function layoutToStore( + userId: string, + incoming: Partial, +): Promise { + let toSerialize = incoming; + const incomingChartOverlayPrefs = toSerialize.chartOverlayPrefs; + const needsRangePointsPreserve = + incomingChartOverlayPrefs !== undefined && + CHART_OVERLAY_KEYS.some((chartKey) => { + const prefs = incomingChartOverlayPrefs[chartKey]; + return prefs !== undefined && prefs.rangePoints === undefined; + }); + const needsPreserveRead = + layoutNeedsPreserveRead(toSerialize) || needsRangePointsPreserve; + if (needsPreserveRead) { + const existing = await prisma.user.findUnique({ + where: { id: userId }, + select: { dashboardWidgetsJson: true }, + }); + const existingLayout = resolveDashboardLayout( + existing?.dashboardWidgetsJson, + ); + toSerialize = mergePreservedLayoutFields(toSerialize, existingLayout); + + if (needsRangePointsPreserve && toSerialize.chartOverlayPrefs) { + let mergedChartOverlayPrefs = toSerialize.chartOverlayPrefs; + for (const chartKey of CHART_OVERLAY_KEYS) { + const incomingPrefs = toSerialize.chartOverlayPrefs[chartKey]; + const storedRangePoints = + existingLayout.chartOverlayPrefs?.[chartKey]?.rangePoints; + if ( + incomingPrefs !== undefined && + incomingPrefs.rangePoints === undefined && + storedRangePoints !== undefined + ) { + if (mergedChartOverlayPrefs === toSerialize.chartOverlayPrefs) { + mergedChartOverlayPrefs = { ...toSerialize.chartOverlayPrefs }; + } + mergedChartOverlayPrefs[chartKey] = { + ...incomingPrefs, + rangePoints: storedRangePoints, + }; + } + } + toSerialize = { + ...toSerialize, + chartOverlayPrefs: mergedChartOverlayPrefs, + }; + } + } + return serializeDashboardLayout(toSerialize as DashboardLayout); +} diff --git a/src/lib/dashboard/widgets/read-layout.ts b/src/lib/dashboard/widgets/read-layout.ts new file mode 100644 index 000000000..44b6185d3 --- /dev/null +++ b/src/lib/dashboard/widgets/read-layout.ts @@ -0,0 +1,25 @@ +/** + * The stored dashboard layout of one account, resolved for `GET + * /api/dashboard/widgets`: defaults merged in when the account has not + * customised it, with the optimistic-concurrency token beside it. + */ +import { prisma } from "@/lib/db"; +import { + resolveDashboardLayout, + type DashboardLayoutWithToken, +} from "@/lib/dashboard-layout"; + +export async function buildDashboardLayout( + userId: string, +): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + // Issue #581: `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. + select: { dashboardWidgetsJson: true, updatedAt: true }, + }); + const layout = resolveDashboardLayout(row?.dashboardWidgetsJson); + return { ...layout, updatedAt: row?.updatedAt?.toISOString() }; +} diff --git a/src/lib/dashboard/widgets/reset-layout.ts b/src/lib/dashboard/widgets/reset-layout.ts new file mode 100644 index 000000000..68097f8d9 --- /dev/null +++ b/src/lib/dashboard/widgets/reset-layout.ts @@ -0,0 +1,43 @@ +/** + * Reset the web-controlled dashboard choices of one account to their + * defaults, preserving its other layout preferences. + */ +import { prisma, toJson } from "@/lib/db"; +import { + DEFAULT_DASHBOARD_LAYOUT, + resolveDashboardLayout, + serializeDashboardLayout, + type DashboardLayout, +} from "@/lib/dashboard-layout"; + +export async function resetDashboardLayout( + userId: string, +): Promise<{ normalized: DashboardLayout; updatedAt: string }> { + // Under a row lock rather than Serializable isolation: a reset racing a + // chart-overlay toggle on the same account waits for it instead of + // failing with a serialization error (a 500). + return prisma.$transaction(async (tx) => { + await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId} FOR UPDATE`; + const existing = await tx.user.findUnique({ + where: { id: userId }, + select: { dashboardWidgetsJson: true }, + }); + const existingLayout = resolveDashboardLayout( + existing?.dashboardWidgetsJson, + ); + const normalized = serializeDashboardLayout({ + ...existingLayout, + widgets: DEFAULT_DASHBOARD_LAYOUT.widgets, + enabledHeroItemKinds: DEFAULT_DASHBOARD_LAYOUT.enabledHeroItemKinds, + // A reset brings a hidden top card back. + todayCardVisible: DEFAULT_DASHBOARD_LAYOUT.todayCardVisible, + }); + + const updated = await tx.user.update({ + where: { id: userId }, + data: { dashboardWidgetsJson: toJson(normalized) }, + select: { updatedAt: true }, + }); + return { normalized, updatedAt: updated.updatedAt.toISOString() }; + }); +} diff --git a/src/lib/dashboard/widgets/unavailable-widgets.ts b/src/lib/dashboard/widgets/unavailable-widgets.ts new file mode 100644 index 000000000..ed68ee41d --- /dev/null +++ b/src/lib/dashboard/widgets/unavailable-widgets.ts @@ -0,0 +1,44 @@ +/** + * Which dashboard toggles cannot do anything for this account. Resolved on + * the server, so the Settings screen never has to fetch dashboard data to + * find out whether a switch it is about to draw is dead. + * + * Deliberately OUTSIDE the cached layout: the layout cell is keyed on the + * user and holds their arrangement, which does not change when a module is + * toggled. Folding a module-dependent answer into it would serve a stale one + * for the rest of the 5-minute TTL — the switch would linger for minutes + * after Recovery went off, which is the confusion this is removing. + */ +import { prisma } from "@/lib/db"; +import { unavailableWidgetIds } from "@/lib/dashboard/widget-modules"; +import { isModuleEnabled } from "@/lib/modules/gate"; + +export async function resolveUnavailableWidgetIds( + userId: string, +): Promise { + // The HRV probes are index-only existence checks + // (`@@index([userId, type, measuredAt])`), not a summaries read; it runs + // only when Recovery is off, so the common account pays nothing. + // One flag, read the way this codebase reads one flag — not the whole + // module map. `hrv` is the only conditionally-dead widget and `recovery` + // is the only module that decides it. + const recoveryEnabled = await isModuleEnabled(userId, "recovery"); + let hasSdnn = true; + let hasRmssd = true; + if (!recoveryEnabled) { + const [sdnnRow, rmssdRow] = await Promise.all( + (["HEART_RATE_VARIABILITY", "HRV_RMSSD"] as const).map((type) => + prisma.measurement.findFirst({ + where: { userId, type, deletedAt: null }, + select: { id: true }, + }), + ), + ); + hasSdnn = sdnnRow !== null; + hasRmssd = rmssdRow !== null; + } + return unavailableWidgetIds( + { recovery: recoveryEnabled }, + { hasSdnn, hasRmssd }, + ); +} diff --git a/src/lib/dashboard/widgets/unknown-ids.ts b/src/lib/dashboard/widgets/unknown-ids.ts new file mode 100644 index 000000000..ab454793a --- /dev/null +++ b/src/lib/dashboard/widgets/unknown-ids.ts @@ -0,0 +1,48 @@ +/** + * Accept-and-ignore widget ids OUTSIDE the catalogue on write. + * + * Every catalogue id (web and iOS-only) validates and persists; only a + * genuinely-unknown id (a retired tile, or a typo) is filtered out here + * BEFORE Zod so it can't 422 the whole blob. The strict enum still validates + * the surviving ids, so a malformed entry (non-string id, missing `order`) + * still 422s. An id outside the catalogue silently vanishes — acceptable, + * and greppable via the annotation. + */ +import { DASHBOARD_WIDGET_CATALOGUE_IDS } from "@/lib/dashboard-layout"; +import { annotate } from "@/lib/logging/context"; + +/** Drops unknown ids from `body.widgets` in place, and says so on the event. */ +export function dropUnknownWidgetIds(body: unknown): void { + const knownWidgetIds = new Set(DASHBOARD_WIDGET_CATALOGUE_IDS); + if ( + body && + typeof body === "object" && + Array.isArray((body as { widgets?: unknown }).widgets) + ) { + const widgetsBody = body as { + widgets: Array<{ id?: unknown }>; + }; + const droppedIds = widgetsBody.widgets + .map((w) => w?.id) + .filter( + (id): id is string => typeof id === "string" && !knownWidgetIds.has(id), + ); + if (droppedIds.length > 0) { + widgetsBody.widgets = widgetsBody.widgets.filter( + (w) => !(typeof w?.id === "string" && !knownWidgetIds.has(w.id)), + ); + // The unknown-id filter runs over the FULL incoming `widgets` array + // BEFORE Zod's `.max()` applies, so `droppedIds` is bounded only by + // the request body limit. Cap the logged array (keep the full + // `dropped_count`) so a single large request can't push thousands of + // strings into one wide-event line. + annotate({ + action: { name: "dashboard.widgets.unknown-id-dropped" }, + meta: { + dropped_ids: droppedIds.slice(0, 20), + dropped_count: droppedIds.length, + }, + }); + } + } +} diff --git a/src/lib/dashboard/widgets/validation-report.ts b/src/lib/dashboard/widgets/validation-report.ts new file mode 100644 index 000000000..88fe06ea9 --- /dev/null +++ b/src/lib/dashboard/widgets/validation-report.ts @@ -0,0 +1,35 @@ +/** + * The wide-event line a refused dashboard-layout save leaves behind. + * + * Every issue is surfaced (iOS contract debugging used to take one round + * trip per wrong field), next to the shape of the payload the client sent, + * so a single line carries enough detail to diagnose serialiser drift in the + * native client. Only the top-level keys plus a hard 256-char JSON excerpt + * are logged — never the full body — so PII / token-like fields cannot leak. + * The diagnostic shape is built by the shared `buildPayloadDiagnostic` + * helper so the widget and series routes can't drift; the body is routed + * through `redactForExcerpt` first so any field matching the denylist + * (password / token / secret / apiKey / authorization / csrfState / nonce) + * lands as the literal `"[redacted]"` instead of its raw value. + */ +import type { z } from "zod/v4"; + +import { buildPayloadDiagnostic, sanitiseZodIssues } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { redactForExcerpt } from "@/lib/observability/redact-payload"; + +export function reportLayoutValidationFailure( + body: unknown, + error: z.ZodError, +): void { + const issues = sanitiseZodIssues(error.issues); + const payloadDiagnostic = buildPayloadDiagnostic(redactForExcerpt(body)); + annotate({ + action: { name: "dashboard.widgets.validation-failed" }, + meta: { + issue_count: issues.length, + ...payloadDiagnostic, + zod_issues: issues, + }, + }); +}