diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index e3e6650f8..76a7f59ce 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -201,10 +201,12 @@ async function handleChatRequest(request: NextRequest): Promise { // slipped past the regex bank on a previous turn would re-enter // the prompt every reply. Re-run the detector against every // user-turn re-loaded from DB; on a hit, short-circuit the SSE - // with a refusal AND drop an `audit.coach.replay-injection` + // with a refusal AND drop an `insights.coach.replay_injection` // row so the failure case is observable. The audit row carries // the conversation id (server-owned), the turn index (no PII) - // and the matched reason — never the message content. + // and the matched reason — never the message content. v1.4.43 + // W10 simplifier-L-2 — action name follows the `.` + // convention (no `audit.` prefix; no `.replay-injection` dash). for (let i = 0; i < priorTurns.length; i++) { const turn = priorTurns[i]; if (turn.role !== "user") continue; @@ -214,7 +216,7 @@ async function handleChatRequest(request: NextRequest): Promise { action: { name: "insights.coach.replay_injection" }, meta: { reason: replayed.reason, turnIndex: i }, }); - await auditLog("audit.coach.replay-injection", { + await auditLog("insights.coach.replay_injection", { userId, details: { conversationId: existing.id, diff --git a/src/app/api/integrations/withings/resume/route.ts b/src/app/api/integrations/withings/resume/route.ts index ca6ab21e0..0065add4b 100644 --- a/src/app/api/integrations/withings/resume/route.ts +++ b/src/app/api/integrations/withings/resume/route.ts @@ -22,8 +22,6 @@ * idempotent — a second click in the same second sees the same * post-state and writes no extra audit row. */ -import type { NextRequest } from "next/server"; - import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, apiError } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -32,8 +30,7 @@ import { resumeIntegrationFromPark } from "@/lib/integrations/status"; export const dynamic = "force-dynamic"; -export const POST = apiHandler(async (request: NextRequest) => { - void request; +export const POST = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "integrations.withings.resume" } }); diff --git a/src/components/doctor-report/__tests__/doctor-report-section-toggles.test.tsx b/src/components/doctor-report/__tests__/doctor-report-section-toggles.test.tsx index 90bbf63fc..833c6560d 100644 --- a/src/components/doctor-report/__tests__/doctor-report-section-toggles.test.tsx +++ b/src/components/doctor-report/__tests__/doctor-report-section-toggles.test.tsx @@ -65,8 +65,11 @@ describe(" v1.4.43 M4 disabled-row behaviour", () => { ); expect(html).toMatch(/data-unavailable="true"[\s\S]*?Sleep duration/); - // The strike-through class fires for at least one disabled row. - expect(html).toMatch(/line-through[\s\S]*?Sleep duration/); + // v1.4.43 W10 design-M4 — disabled rows render with muted text + // colour (not strike-through, which reads as "deleted" rather + // than "unavailable"). The italic hint line below carries the + // semantic visually. + expect(html).toMatch(/text-muted-foreground[\s\S]*?Sleep duration/); }); it("renders the unavailable-hint tooltip text via the title attribute", () => { diff --git a/src/components/doctor-report/doctor-report-dialog.tsx b/src/components/doctor-report/doctor-report-dialog.tsx index d730002c6..ad10befec 100644 --- a/src/components/doctor-report/doctor-report-dialog.tsx +++ b/src/components/doctor-report/doctor-report-dialog.tsx @@ -571,8 +571,8 @@ export function SectionToggles({ {t("doctorReport.sections.title")}

-
-
+
+
); @@ -639,7 +639,7 @@ export function SectionToggles({ {t(SECTION_LABEL_KEYS[key])} diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx index ae230666f..846e99a9d 100644 --- a/src/components/insights/daily-briefing.tsx +++ b/src/components/insights/daily-briefing.tsx @@ -227,10 +227,10 @@ function BriefingSkeleton() { aria-hidden="true" >
-
-
-
-
+
+
+
+
{[0, 1, 2].map((i) => ( @@ -238,7 +238,7 @@ function BriefingSkeleton() { key={i} className="border-border/40 bg-card/30 flex h-16 items-center rounded-md border p-3" > -
+
))}
diff --git a/src/components/insights/trend-annotation.tsx b/src/components/insights/trend-annotation.tsx index b16945b7d..3ebdc243a 100644 --- a/src/components/insights/trend-annotation.tsx +++ b/src/components/insights/trend-annotation.tsx @@ -97,9 +97,9 @@ export function TrendAnnotation({ className="border-border/60 bg-card/40 space-y-1.5 rounded-md border p-3 motion-reduce:animate-none" aria-label={t("insights.trendAnnotation.pendingLabel")} > -
-
-
+
+
+
); } diff --git a/src/components/onboarding/WelcomeCarousel.tsx b/src/components/onboarding/WelcomeCarousel.tsx index 3f2899ee6..b97146bd6 100644 --- a/src/components/onboarding/WelcomeCarousel.tsx +++ b/src/components/onboarding/WelcomeCarousel.tsx @@ -16,6 +16,7 @@ import { import { Button } from "@/components/ui/button"; import { useTranslations } from "@/lib/i18n/context"; import { readError } from "@/lib/api/read-error"; +import { scrollBehaviorForUser } from "@/lib/motion"; import { cn } from "@/lib/utils"; /** @@ -119,11 +120,8 @@ export function WelcomeCarousel() { `[data-slide-index="${idx}"]`, ); if (!target) return; - const reduceMotion = window.matchMedia( - "(prefers-reduced-motion: reduce)", - ).matches; target.scrollIntoView({ - behavior: reduceMotion ? "auto" : "smooth", + behavior: scrollBehaviorForUser(), inline: "start", block: "nearest", }); diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx index e2941b446..daff86a60 100644 --- a/src/components/ui/switch.tsx +++ b/src/components/ui/switch.tsx @@ -20,10 +20,12 @@ function Switch({ // v1.4.43 W5-H1 — extend the WCAG 2.5.5 hit target to 44×44 px via // a `::before` pseudo-element while leaving the visual track at // 18×32 px. The same trick is used on Input (v1.4.34 IW-G). The - // wrapping `relative` and `before:absolute … before:inset-[-12px]` + // wrapping `relative` and `before:absolute … before:inset-[-13px]` // pair guarantees pointer events bubble up from the larger zone - // without disturbing layout. - "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none before:absolute before:inset-[-12px] before:content-[''] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6", + // without disturbing layout. v1.4.43 W10 design-M1 — `-13px` (not + // `-12px`) takes the 18.4 px track + 26 px padding to 44.4 px and + // closes the 1.6 px vertical short fall to spec-strict 2.5.5. + "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none before:absolute before:inset-[-13px] before:content-[''] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6", className, )} {...props} diff --git a/src/lib/auth/passkey.ts b/src/lib/auth/passkey.ts index fc8a94748..fe6c3a338 100644 --- a/src/lib/auth/passkey.ts +++ b/src/lib/auth/passkey.ts @@ -42,6 +42,31 @@ const authenticationResponseSchema = z }) .loose(); +// v1.4.43 W10 senior-dev L-1 — symmetric Zod narrowing in front of +// `verifyRegistration`. SimpleWebAuthn's `RegistrationResponseJSON` +// shape: https://w3c.github.io/webauthn/#dictdef-registrationresponsejson +// `attestationObject` replaces `authenticatorData + signature` from +// the authentication shape; everything else mirrors the auth schema. +const registrationResponseSchema = z + .object({ + id: z.string().min(1), + rawId: z.string().min(1), + response: z + .object({ + clientDataJSON: z.string().min(1), + attestationObject: z.string().min(1), + transports: z.array(z.string()).optional(), + publicKeyAlgorithm: z.number().optional(), + publicKey: z.string().optional(), + authenticatorData: z.string().optional(), + }) + .loose(), + authenticatorAttachment: z.string().optional(), + clientExtensionResults: z.unknown().optional(), + type: z.literal("public-key"), + }) + .loose(); + type Transport = | "ble" | "cable" @@ -145,12 +170,18 @@ export async function verifyRegistration( } try { - // The caller passes a parsed JSON body without prior shape-validation — - // the SimpleWebAuthn verifier owns full schema validation and throws - // on any mismatch. We narrow at the boundary so the rest of the file - // sees the documented `RegistrationResponseJSON` shape. + // v1.4.43 W10 senior-dev L-1 — Zod narrow at the boundary before + // delegating to SimpleWebAuthn's full cryptographic validation. + // A malformed body now fails fast with a structured Zod error + // rather than crashing on a follow-up `.id` deref deeper in the + // verifier. Mirrors the v1.4.43 W13 L-3 narrowing on the + // authentication side. + const parsed = registrationResponseSchema.safeParse(response); + if (!parsed.success) { + throw new Error("Registration response shape invalid"); + } const verification = await verifyRegistrationResponse({ - response: response as RegistrationResponseJSON, + response: parsed.data as RegistrationResponseJSON, expectedChallenge: challenge.challenge, expectedOrigin: getExpectedOrigin(), expectedRPID: getRpId(), diff --git a/tests/integration/coach-chat.test.ts b/tests/integration/coach-chat.test.ts index 947c3c26f..8ea8e62e5 100644 --- a/tests/integration/coach-chat.test.ts +++ b/tests/integration/coach-chat.test.ts @@ -447,7 +447,7 @@ describe("POST /api/insights/chat — integration", () => { // Audit row is the durable observable signal — assert it landed // with the conversation id + turn index, no message content. const auditRows = await prisma.auditLog.findMany({ - where: { action: "audit.coach.replay-injection" }, + where: { action: "insights.coach.replay_injection" }, }); expect(auditRows).toHaveLength(1); expect(auditRows[0].userId).toBe(userId);