mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(reconcile): close W10 QA Mediums and Lows
Bundle of small reconcile fixes from the W10 QA round: - Switch tap-target padding -12 px -> -13 px closes the 1.6 px vertical short-fall to spec-strict WCAG 2.5.5 (design M1). - animate-pulse skeleton sites in daily-briefing, trend-annotation and doctor-report dialog now carry motion-reduce:animate-none (design M2). - Doctor-report unavailable rows render with text-muted-foreground instead of line-through so the visual reads as 'unavailable' rather than 'deleted'; the italic hint line below still carries the semantic (design M4). - WelcomeCarousel scrollIntoView now reads scrollBehaviorForUser from src/lib/motion so all four advertised call-sites share the helper (simplifier L-1). - Coach replay-injection audit row renamed audit.coach.replay-injection -> insights.coach.replay_injection to match the <surface>.<verb> convention across the audit ledger (simplifier L-2). - /api/integrations/withings/resume drops the unused request param (the handler reads neither body nor headers; requireAuth scopes the call) (simplifier M-4). - verifyRegistration body gets the same Zod boundary narrow W13 L-3 added to verifyAuthentication; SimpleWebAuthn still owns cryptographic validation (senior-dev L-1).
This commit is contained in:
1 parent
f1e16bf713
commit
468e38c3ef
10 files changed
+66
-33
No files matched your search
@@ -201,10 +201,12 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
// 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 `<surface>.<verb>`
|
||||
// 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<Response> {
|
||||
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,
|
||||
|
||||
@@ -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" } });
|
||||
|
||||
|
||||
@@ -65,8 +65,11 @@ describe("<SectionToggles> 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", () => {
|
||||
|
||||
@@ -571,8 +571,8 @@ export function SectionToggles({
|
||||
{t("doctorReport.sections.title")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted h-9 animate-pulse rounded-md" />
|
||||
<div className="bg-muted h-9 animate-pulse rounded-md" />
|
||||
<div className="bg-muted h-9 animate-pulse motion-reduce:animate-none rounded-md" />
|
||||
<div className="bg-muted h-9 animate-pulse motion-reduce:animate-none rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -639,7 +639,7 @@ export function SectionToggles({
|
||||
<span className="flex flex-col">
|
||||
<span
|
||||
className={`text-sm leading-tight font-medium ${
|
||||
isAvailable ? "" : "line-through"
|
||||
isAvailable ? "" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{t(SECTION_LABEL_KEYS[key])}
|
||||
|
||||
@@ -227,10 +227,10 @@ function BriefingSkeleton() {
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted/60 h-3 w-11/12 animate-pulse rounded" />
|
||||
<div className="bg-muted/60 h-3 w-10/12 animate-pulse rounded" />
|
||||
<div className="bg-muted/60 h-3 w-9/12 animate-pulse rounded" />
|
||||
<div className="bg-muted/60 h-3 w-8/12 animate-pulse rounded" />
|
||||
<div className="bg-muted/60 h-3 w-11/12 animate-pulse motion-reduce:animate-none rounded" />
|
||||
<div className="bg-muted/60 h-3 w-10/12 animate-pulse motion-reduce:animate-none rounded" />
|
||||
<div className="bg-muted/60 h-3 w-9/12 animate-pulse motion-reduce:animate-none rounded" />
|
||||
<div className="bg-muted/60 h-3 w-8/12 animate-pulse motion-reduce:animate-none rounded" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{[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"
|
||||
>
|
||||
<div className="bg-muted/60 h-3 w-1/3 animate-pulse rounded" />
|
||||
<div className="bg-muted/60 h-3 w-1/3 animate-pulse motion-reduce:animate-none rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
<div className="bg-muted/60 h-2.5 w-11/12 animate-pulse rounded" />
|
||||
<div className="bg-muted/60 h-2.5 w-9/12 animate-pulse rounded" />
|
||||
<div className="bg-muted/60 h-2.5 w-7/12 animate-pulse rounded" />
|
||||
<div className="bg-muted/60 h-2.5 w-11/12 animate-pulse motion-reduce:animate-none rounded" />
|
||||
<div className="bg-muted/60 h-2.5 w-9/12 animate-pulse motion-reduce:animate-none rounded" />
|
||||
<div className="bg-muted/60 h-2.5 w-7/12 animate-pulse motion-reduce:animate-none rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
+36
-5
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user