chore(release): v1.4.24

Security + accessibility hardening release. Closes the highest-priority
findings from a focused security audit of the v1.4.23 release:

- Bearer auth fail-closed when a route declares no scope (api-handler)
- Public /api/send Umami proxy now rate-limits 120 req/min/IP
- Proxy nonce generation switched to btoa(...) for Edge portability
- Session-expiry delete race uses deleteMany + catch (no more 500s)
- POST /api/import returns the standard apiError envelope on 422
- Runtime Docker image pins Prisma 7.8 + worker deps to minors
- Notifications page renders a real load-error alert
- Accessibility: switch aria-labels, htmlFor pairing, mobile admin
  Overview link, deep-link anchors on notification settings
- Chart overlays: per-chart comparisonBaseline pref, BMI/bodyFat/
  sleep/steps overlay keys, TrendCard layout tolerates long labels

Verification: pnpm typecheck && pnpm lint && pnpm test (2244/2244)
&& pnpm format:check all green.
This commit is contained in:
Marc-André Bombeck committed 2026-05-12 22:33:35 +02:00
1 parent 0dc0e1645c
commit ca03c6099f
59 files changed
+841 -206

No files matched your search

@@ -128,4 +128,27 @@ describe("proxy.ts onboarding redirect (v1.4.22 C4)", () => {
expect(res.status).toBe(200);
expect(res.headers.get("location")).toBeNull();
});
it("generates CSP nonces without relying on Node Buffer", () => {
const originalBuffer = (
globalThis as typeof globalThis & { Buffer?: Buffer }
).Buffer;
Object.defineProperty(globalThis, "Buffer", {
value: undefined,
configurable: true,
});
try {
const res = proxy(makeRequest("/", { healthlog_session: "sess-1" }));
expect(res.status).toBe(200);
expect(res.headers.get("Content-Security-Policy")).toMatch(
/nonce-'?[A-Za-z0-9+/]{22}==/,
);
} finally {
Object.defineProperty(globalThis, "Buffer", {
value: originalBuffer,
configurable: true,
});
}
});
});
@@ -6,6 +6,10 @@ import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
export const dynamic = "force-dynamic";
function redact(text: string): string {
return text.replace(/https?:\/\/\S+/gi, "[url]");
}
function resolveUmamiSendUrls(scriptUrl: string | null): string[] {
if (!scriptUrl) return [];
try {
@@ -112,6 +116,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
break;
}
getEvent()?.addWarning("Umami test event rejected: " + lastStatus);
getEvent()?.addWarning(
`Umami test event rejected: ${lastStatus}${lastDetails ? ` ${redact(lastDetails)}` : ""}`,
);
return apiError(`Umami test event rejected (HTTP ${lastStatus})`, 502);
});
+1 -2
View File
@@ -308,10 +308,9 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
// HealthKit pulse samples) cannot allocate an unbounded buffer. The
// helper still returns the full filtered series the Pearson runners
// need; we just bound the per-page Prisma round-trip.
const [sysRows, diaRows, pulseRows, weightRows, moodRows, intakeRows] =
const [sysRows, pulseRows, weightRows, moodRows, intakeRows] =
await Promise.all([
fetchMeasurementSeriesChunked(userId, "BLOOD_PRESSURE_SYS", { since }),
fetchMeasurementSeriesChunked(userId, "BLOOD_PRESSURE_DIA", { since }),
fetchMeasurementSeriesChunked(userId, "PULSE", { since }),
fetchMeasurementSeriesChunked(userId, "WEIGHT", { since }),
prisma.moodEntry.findMany({
+2 -1
View File
@@ -22,7 +22,8 @@ import { apiSuccess } from "@/lib/api-response";
* persisted server-side via an encrypted httpOnly cookie scoped to a
* 15-minute lifetime — the same window Hydra gives the code.
*/
export const POST = apiHandler(async (_request: NextRequest) => {
export const POST = apiHandler(async (request: NextRequest) => {
void request;
const { user } = await requireAuth();
const rl = await checkRateLimit(`codex-device-start:${user.id}`, 5, 60_000);
+1 -1
View File
@@ -60,7 +60,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (appSettings?.githubIssueTokenEncrypted) {
try {
configuredToken = decrypt(appSettings.githubIssueTokenEncrypted);
} catch (err) {
} catch {
getEvent()?.addWarning("Failed to decrypt GitHub issue token");
}
}
@@ -35,6 +35,9 @@ const prefsSchema = z.object({
showTrendIndicator: z.boolean(),
showTrendArrow: z.boolean(),
showTargetRange: z.boolean(),
comparisonBaseline: z
.enum(["none", "lastMonth", "lastYear"])
.default("none"),
}),
});
@@ -85,4 +85,25 @@ describe("POST /api/import — rate-limit guard", () => {
expect(response.status).toBeLessThan(400);
});
it("returns the standard apiError envelope for invalid payloads", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 4,
resetAt: new Date(Date.now() + 1000),
} as never);
const response = await POST(
new NextRequest("http://localhost/api/import", {
method: "POST",
body: JSON.stringify({ measurements: "not-an-array" }),
headers: { "content-type": "application/json" },
}),
);
expect(response.status).toBe(422);
const body = (await response.json()) as ApiErrorEnvelope;
expect(body.data).toBeNull();
expect(body.error).toBeTruthy();
});
});
+4 -1
View File
@@ -70,7 +70,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (jsonError) return jsonError;
const parsed = importSchema.safeParse(body);
if (!parsed.success) {
return apiSuccess({ error: parsed.error.issues[0].message }, 422);
return apiError(
parsed.error.issues[0]?.message ?? "Invalid import payload",
422,
);
}
const userId = user.id;
@@ -238,18 +238,16 @@ describe("POST /api/insights/generate — rate limit (v1.4.16 A7.1)", () => {
makeWorkingProvider();
// Simulate 10 successful checkRateLimit responses, then one denial.
let callCount = 0;
vi.mocked(checkRateLimit).mockImplementation(
async (_key, limit, _windowMs) => {
callCount += 1;
// The route must pass `10` as the limit when the env var is unset.
expect(limit).toBe(10);
return {
allowed: callCount <= 10,
remaining: Math.max(0, 10 - callCount),
resetAt: Date.now() + 3600_000,
};
},
);
vi.mocked(checkRateLimit).mockImplementation(async (_key, limit) => {
callCount += 1;
// The route must pass `10` as the limit when the env var is unset.
expect(limit).toBe(10);
return {
allowed: callCount <= 10,
remaining: Math.max(0, 10 - callCount),
resetAt: Date.now() + 3600_000,
};
});
for (let i = 0; i < 10; i += 1) {
const res = await POST(jsonRequest({ force: true }) as never);
@@ -48,7 +48,8 @@ function redact(text: string): string {
.replace(/https?:\/\/\S+/gi, "[url]");
}
export const POST = apiHandler(async (_request: NextRequest) => {
export const POST = apiHandler(async (request: NextRequest) => {
void request;
const { user } = await requireAuth();
annotate({ action: { name: "integrations.moodlog.test" } });
@@ -65,7 +65,8 @@ function categoriseApiStatus(apiStatus: number): CategorisedError {
}
}
export const POST = apiHandler(async (_request: NextRequest) => {
export const POST = apiHandler(async (request: NextRequest) => {
void request;
const { user } = await requireAuth();
annotate({ action: { name: "integrations.withings.test" } });
+1 -1
View File
@@ -71,7 +71,7 @@ export const GET = apiHandler(async () => {
let categoryMap: Record<string, string> = {};
try {
categoryMap = await getMedicationCategories(medications.map((m) => m.id));
} catch (error) {
} catch {
getEvent()?.addWarning("Medication categories could not be loaded");
}
@@ -11,7 +11,8 @@ export const dynamic = "force-dynamic";
const TIMEOUT_MS = 5_000;
export const POST = apiHandler(async (_request: NextRequest) => {
export const POST = apiHandler(async (request: NextRequest) => {
void request;
const { user } = await requireAdmin();
annotate({ action: { name: "monitoring.glitchtip.test" } });
+2 -1
View File
@@ -15,7 +15,8 @@ function redact(text: string): string {
return text.replace(/https?:\/\/\S+/gi, "[url]");
}
export const POST = apiHandler(async (_request: NextRequest) => {
export const POST = apiHandler(async (request: NextRequest) => {
void request;
const { user } = await requireAdmin();
annotate({ action: { name: "monitoring.umami.test" } });
@@ -62,7 +62,7 @@ export const GET = apiHandler(async () => {
channels.push(created);
}
} catch (err) {
} catch {
getEvent()?.addWarning("Legacy Telegram migration failed");
}
}
@@ -25,7 +25,8 @@ function redactPushError(text: string): string {
return text.replace(/https?:\/\/\S+/gi, "[endpoint]");
}
export const POST = apiHandler(async (_request: NextRequest) => {
export const POST = apiHandler(async (request: NextRequest) => {
void request;
const { user } = await requireAuth();
annotate({ action: { name: "notifications.web-push.test" } });
+88
View File
@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/monitoring-settings", () => ({
getPublicMonitoringSettings: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
import { POST } from "../route";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
import { checkRateLimit } from "@/lib/rate-limit";
beforeEach(() => {
vi.resetAllMocks();
vi.stubGlobal("fetch", vi.fn());
});
describe("POST /api/send", () => {
it("rate-limits the public Umami proxy by client IP", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const response = await POST(
new NextRequest("http://localhost/api/send", {
method: "POST",
body: JSON.stringify({ type: "event" }),
headers: {
"content-type": "application/json",
"x-forwarded-for": "203.0.113.10",
},
}),
);
expect(response.status).toBe(429);
expect(checkRateLimit).toHaveBeenCalledWith(
"umami-proxy:203.0.113.10",
120,
60 * 1000,
);
expect(getPublicMonitoringSettings).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
it("continues to proxy when the request is within quota", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 119,
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(getPublicMonitoringSettings).mockResolvedValue({
umamiEnabled: true,
umamiScriptUrl: "https://analytics.example.com/script.js",
umamiWebsiteId: "site-1",
} as never);
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 202 }));
const response = await POST(
new NextRequest("http://localhost/api/send", {
method: "POST",
body: JSON.stringify({ type: "event" }),
headers: {
"content-type": "application/json",
"x-real-ip": "203.0.113.11",
},
}),
);
expect(response.status).toBe(202);
expect(fetch).toHaveBeenCalledWith(
"https://analytics.example.com/api/send",
expect.objectContaining({ method: "POST" }),
);
});
});
+6
View File
@@ -1,7 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
import { checkRateLimit } from "@/lib/rate-limit";
export const dynamic = "force-dynamic";
@@ -68,6 +70,10 @@ function resolveUmamiSendUrls(scriptUrl: string | null): string[] {
export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "umami.proxy" } });
const ip = getClientIp(request) ?? "unknown";
const rl = await checkRateLimit(`umami-proxy:${ip}`, 120, 60 * 1000);
if (!rl.allowed) return apiError("Rate limit exceeded", 429);
const settings = await getPublicMonitoringSettings();
if (!settings.umamiEnabled) {
return NextResponse.json({}, { status: 204 });
+27 -1
View File
@@ -45,7 +45,7 @@ export default function NotificationsPage() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
const { data, isLoading, isError } = useQuery({
queryKey: ["notifications", "preferences"],
queryFn: async () => {
const res = await fetch("/api/notifications/preferences");
@@ -144,6 +144,27 @@ export default function NotificationsPage() {
const eventTypes = data?.eventTypes ?? [];
const activeChannels = channels.filter((ch) => ch.globallyEnabled);
if (isError) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("notifications.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("notifications.subtitle")}
</p>
</div>
<div
role="alert"
className="border-destructive/40 bg-destructive/10 text-destructive rounded-lg border p-4 text-sm"
>
{t("notifications.loadError")}
</div>
</div>
);
}
// No channels configured
if (activeChannels.length === 0) {
return (
@@ -236,9 +257,11 @@ export default function NotificationsPage() {
{activeChannels.map((ch) => {
const enabled = isEnabled(ch.id, eventType);
const disabled = !ch.enabled;
const switchLabel = `${t(nameKey)} - ${ch.label}`;
return (
<td key={ch.id} className="px-4 py-3 text-center">
<Switch
aria-label={switchLabel}
checked={enabled && !disabled}
disabled={disabled || toggleMutation.isPending}
onCheckedChange={(checked: boolean) =>
@@ -279,12 +302,14 @@ export default function NotificationsPage() {
{activeChannels.map((ch) => {
const enabled = isEnabled(ch.id, eventType);
const disabled = !ch.enabled;
const switchId = `notification-${eventType}-${ch.id}`;
return (
<div
key={ch.id}
className="flex items-center justify-between"
>
<Label
htmlFor={switchId}
className={`text-sm ${disabled ? "text-muted-foreground/70" : ""}`}
>
{ch.label}
@@ -295,6 +320,7 @@ export default function NotificationsPage() {
)}
</Label>
<Switch
id={switchId}
checked={enabled && !disabled}
disabled={disabled || toggleMutation.isPending}
onCheckedChange={(checked: boolean) =>
+6 -2
View File
@@ -967,6 +967,7 @@ export default function DashboardPage() {
node: (
<HealthChart
key="bmi-chart"
chartKey="bmi"
types={["WEIGHT"]}
title={t("targets.bmi")}
colors={["#f1fa8c"]}
@@ -1032,6 +1033,7 @@ export default function DashboardPage() {
node: (
<HealthChart
key="bodyFat-chart"
chartKey="bodyFat"
types={["BODY_FAT"]}
title={t("dashboard.bodyFat")}
colors={["#ffb86c"]}
@@ -1064,6 +1066,7 @@ export default function DashboardPage() {
node: (
<HealthChart
key="sleep-chart"
chartKey="sleep"
types={["SLEEP_DURATION"]}
title={t("dashboard.sleep") ?? "Sleep"}
colors={["#8be9fd"]}
@@ -1081,6 +1084,7 @@ export default function DashboardPage() {
node: (
<HealthChart
key="steps-chart"
chartKey="steps"
types={["ACTIVITY_STEPS"]}
title={t("dashboard.steps") ?? "Steps"}
colors={["#50fa7b"]}
@@ -1186,13 +1190,13 @@ export default function DashboardPage() {
// scrolling — the maintainer tested both and prefers the v1.3-era
// wrap behaviour over the one-row scroll for the symmetry
// it preserves.
className="grid auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(9rem,1fr))] gap-3 pb-2"
className="grid auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(min(100%,9rem),1fr))] gap-3 pb-2"
data-slot="dashboard-tile-strip"
data-tour-id="dashboard-tile-strip"
data-tile-count={trendCards.length}
>
{trendCards.map((entry) => (
<div key={entry.id} className="flex">
<div key={entry.id} className="flex min-w-0">
{entry.node}
</div>
))}
+15
View File
@@ -165,6 +165,21 @@ export function AdminShell({ active, children }: AdminShellProps) {
className="no-scrollbar -mx-4 mb-4 overflow-x-auto px-4 md:hidden"
>
<ul className="flex min-w-max gap-2">
<li>
<Link
href="/admin"
aria-current={activeSlug === null ? "page" : undefined}
className={cn(
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
activeSlug === null
? "border-primary/40 bg-primary/10 text-primary"
: "border-border text-foreground hover:bg-accent",
)}
>
<ScrollText className="h-4 w-4" aria-hidden="true" />
{t("admin.shell.overview")}
</Link>
</li>
{ADMIN_SECTIONS.map((section) => {
const isActive = section.slug === activeSlug;
const Icon = section.icon;
+1 -8
View File
@@ -2,14 +2,7 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import {
Activity,
Bell,
CheckCircle2,
Clock,
Loader2,
XCircle,
} from "lucide-react";
import { Activity, Bell, CheckCircle2, Loader2, XCircle } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -52,6 +52,7 @@ describe("<ChartOverlayControls>", () => {
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: false,
comparisonBaseline: "none",
});
});
@@ -78,6 +79,7 @@ describe("<ChartOverlayControls>", () => {
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: true,
comparisonBaseline: "none",
});
});
@@ -105,6 +105,18 @@ describe("<HealthChart mini>", () => {
expect(html).toContain("chart-overlay-controls-trigger");
});
it("renders the overlay-controls trigger for BMI charts", () => {
const html = render(
<HealthChart
types={["WEIGHT"]}
title="BMI"
chartKey="bmi"
valueMode="bmi"
/>,
);
expect(html).toContain("chart-overlay-controls-trigger");
});
it("accepts windowOverride and applies the matching range-points", () => {
const html = render(
<HealthChart
@@ -47,6 +47,7 @@ const mockPrefs = vi.hoisted(() => ({
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: false,
comparisonBaseline: "none",
},
}));
@@ -75,6 +76,7 @@ describe("MedicationComplianceChart overlay toggles", () => {
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: false,
comparisonBaseline: "none",
};
const html = await renderChart();
expect(html).not.toContain("medication-trend-chip");
@@ -85,6 +87,7 @@ describe("MedicationComplianceChart overlay toggles", () => {
showTrendIndicator: true,
showTrendArrow: false,
showTargetRange: false,
comparisonBaseline: "none",
};
const html = await renderChart();
expect(html).toContain("medication-trend-chip");
@@ -122,3 +122,29 @@ describe("<TrendCard> directionSentiment", () => {
expect(html).not.toContain("text-dracula-orange");
});
});
describe("<TrendCard> responsive layout", () => {
it("keeps long BP target tile content wrappable inside the card", () => {
const html = render(
<TrendCard
label="BP in target"
latest={50}
unit="%"
avg7={100}
avg30={50}
avgAllTime={11}
slope30={RISING}
trend7Delta={50}
icon={Activity}
directionSentiment="up-good"
compareBaseline="lastMonth"
compareDelta={36}
/>,
);
expect(html).toContain("min-w-0");
expect(html).toContain("[overflow-wrap:anywhere]");
expect(html).toContain('data-slot="tile-compare-delta"');
expect(html).toContain('data-slot="trend-card-all-time"');
});
});
@@ -14,7 +14,11 @@ import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { type ChartOverlayPrefs } from "@/lib/dashboard-layout";
import {
COMPARISON_BASELINES,
type ChartOverlayPrefs,
type ComparisonBaseline,
} from "@/lib/dashboard-layout";
/**
* v1.4.18 — per-chart overlay-controls popover.
@@ -78,10 +82,17 @@ export function ChartOverlayControls({
const trendArrowId = useId();
const targetRangeId = useId();
const setKey = (key: keyof ChartOverlayPrefs, value: boolean): void => {
const setToggle = (
key: "showTrendIndicator" | "showTrendArrow" | "showTargetRange",
value: boolean,
): void => {
onChange({ ...prefs, [key]: value });
};
const setComparisonBaseline = (value: ComparisonBaseline): void => {
onChange({ ...prefs, comparisonBaseline: value });
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -118,7 +129,7 @@ export function ChartOverlayControls({
id={trendIndicatorId}
checked={prefs.showTrendIndicator}
onCheckedChange={(value) =>
setKey("showTrendIndicator", Boolean(value))
setToggle("showTrendIndicator", Boolean(value))
}
data-slot="chart-overlay-toggle-trend-indicator"
/>
@@ -134,7 +145,7 @@ export function ChartOverlayControls({
id={trendArrowId}
checked={prefs.showTrendArrow}
onCheckedChange={(value) =>
setKey("showTrendArrow", Boolean(value))
setToggle("showTrendArrow", Boolean(value))
}
data-slot="chart-overlay-toggle-trend-arrow"
/>
@@ -150,12 +161,39 @@ export function ChartOverlayControls({
id={targetRangeId}
checked={prefs.showTargetRange}
onCheckedChange={(value) =>
setKey("showTargetRange", Boolean(value))
setToggle("showTargetRange", Boolean(value))
}
data-slot="chart-overlay-toggle-target-range"
/>
</div>
</div>
<DropdownMenuSeparator />
<div className="flex flex-col gap-1.5 pt-1">
<Label className="text-muted-foreground text-xs font-medium">
{t("chart.overlay.controls.comparisonBaseline")}
</Label>
<div
className="grid grid-cols-3 gap-1"
data-slot="chart-overlay-comparison-baseline"
>
{COMPARISON_BASELINES.map((value) => (
<Button
key={value}
type="button"
variant={
prefs.comparisonBaseline === value ? "default" : "outline"
}
size="sm"
className="h-9 px-1 text-[11px]"
onClick={() => setComparisonBaseline(value)}
aria-pressed={prefs.comparisonBaseline === value}
data-slot={`chart-overlay-comparison-${value}`}
>
{t(`comparison.baseline.${value}`)}
</Button>
))}
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
+27 -17
View File
@@ -388,6 +388,9 @@ export function HealthChart({
const showMA = overlayPrefs.prefs.showTrendIndicator;
const showTrend = overlayPrefs.prefs.showTrendArrow;
const showBands = overlayPrefs.prefs.showTargetRange;
const effectiveCompareBaseline = chartKey
? overlayPrefs.prefs.comparisonBaseline
: compareBaseline;
// v1.4.19 A2 — viewport-aware tick density. Recharts' default is one
// tick per data point; the helper caps that at 4-10 visible labels
@@ -605,7 +608,7 @@ export function HealthChart({
// "Comparison unavailable — no data from last month yet" via the
// hasComparisonData flag below).
const chartDataWithCompare = useMemo(() => {
if (!chartData || compareBaseline === "none") return chartData;
if (!chartData || effectiveCompareBaseline === "none") return chartData;
if (!data?.length) return chartData;
const shifted = shiftDailySeriesForward(
@@ -615,7 +618,7 @@ export function HealthChart({
types.map((type) => [type, row[type] as number | undefined]),
),
})),
compareBaseline,
effectiveCompareBaseline,
);
// Index shifted rows by the same day-key the chart already uses.
@@ -643,7 +646,7 @@ export function HealthChart({
}
return merged;
});
}, [chartData, compareBaseline, data, types]);
}, [chartData, effectiveCompareBaseline, data, types]);
/**
* v1.4.16 phase B8 — true when at least one visible day has a prior-
@@ -654,7 +657,8 @@ export function HealthChart({
* when they can clearly see SOME dimmed history.
*/
const hasComparisonData = useMemo(() => {
if (compareBaseline === "none" || !chartDataWithCompare) return false;
if (effectiveCompareBaseline === "none" || !chartDataWithCompare)
return false;
return chartDataWithCompare.some((point) =>
types.some(
(type) =>
@@ -662,7 +666,7 @@ export function HealthChart({
Number.isFinite(point[`${type}_compare`] as number),
),
);
}, [chartDataWithCompare, compareBaseline, types]);
}, [chartDataWithCompare, effectiveCompareBaseline, types]);
const activeBucket: ChartBucketType = useMemo(() => {
if (!data?.length) return "day";
@@ -684,7 +688,7 @@ export function HealthChart({
// v1.4.16 phase B8 — extend the y-domain to fit the prior-period
// overlay. Without this the dimmed line can clip outside the
// visible range when last-month / last-year had a different scale.
if (compareBaseline !== "none") {
if (effectiveCompareBaseline !== "none") {
keys.push(...types.map((type) => `${type}_compare`));
}
@@ -707,7 +711,13 @@ export function HealthChart({
const paddingBottom = Math.max(span * 0.08, 0.5);
const paddingTop = Math.max(span * 0.16, 1);
return [min - paddingBottom, max + paddingTop];
}, [chartDataWithCompare, compareBaseline, showMA, showTrend, types]);
}, [
chartDataWithCompare,
effectiveCompareBaseline,
showMA,
showTrend,
types,
]);
const visibleBands = useMemo(() => {
if (!showBands || !valueBands?.length || !yDomain) return [];
@@ -959,25 +969,25 @@ export function HealthChart({
neutral muted styling so it doesn't read as an error.
v1.4.19 A2 — hidden on mobile to free up the title row. */}
{compareBaseline !== "none" && hasComparisonData && (
{effectiveCompareBaseline !== "none" && hasComparisonData && (
<span
className="text-dracula-purple bg-dracula-purple/10 hidden rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex"
data-slot="chart-compare-caption"
>
{t(
compareBaseline === "lastMonth"
effectiveCompareBaseline === "lastMonth"
? "comparison.captionLastMonth"
: "comparison.captionLastYear",
)}
</span>
)}
{compareBaseline !== "none" && !hasComparisonData && (
{effectiveCompareBaseline !== "none" && !hasComparisonData && (
<span
className="text-muted-foreground bg-muted/40 hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide sm:inline-flex"
data-slot="chart-compare-unavailable"
>
{t(
compareBaseline === "lastMonth"
effectiveCompareBaseline === "lastMonth"
? "comparison.unavailable.lastMonth"
: "comparison.unavailable.lastYear",
)}
@@ -1291,13 +1301,13 @@ export function HealthChart({
// have a prior value for this day. Otherwise fall
// back to the existing baseline-delta path.
const compareValue =
compareBaseline !== "none" && hoverPoint
effectiveCompareBaseline !== "none" && hoverPoint
? (hoverPoint[`${dataKey}_compare`] as
| number
| undefined)
: undefined;
if (
compareBaseline !== "none" &&
effectiveCompareBaseline !== "none" &&
typeof compareValue === "number" &&
Number.isFinite(compareValue)
) {
@@ -1311,7 +1321,7 @@ export function HealthChart({
1,
)}${unit ? ` ${unit}` : ""}`;
delta = t(
compareBaseline === "lastMonth"
effectiveCompareBaseline === "lastMonth"
? "comparison.deltaVs.lastMonth"
: "comparison.deltaVs.lastYear",
).replace("{delta}", formatted);
@@ -1345,7 +1355,7 @@ export function HealthChart({
// numbers (current AND last-month / last-year)
// alongside the delta.
if (
compareBaseline !== "none" &&
effectiveCompareBaseline !== "none" &&
typeof compareValue === "number" &&
Number.isFinite(compareValue)
) {
@@ -1428,7 +1438,7 @@ export function HealthChart({
stroke opacity (45%) and a thinner stroke (1.25)
so the user reads the current line first and the
overlay as orientation. */}
{compareBaseline !== "none" &&
{effectiveCompareBaseline !== "none" &&
hasComparisonData &&
types.map((type, i) => (
<Line
@@ -1436,7 +1446,7 @@ export function HealthChart({
type="monotone"
dataKey={`${type}_compare`}
name={`${getTypeLabel(type, valueMode, t)} (${t(
compareBaseline === "lastMonth"
effectiveCompareBaseline === "lastMonth"
? "comparison.captionLastMonth"
: "comparison.captionLastYear",
)})`}
@@ -169,7 +169,6 @@ interface MedicationComplianceChartProps {
export function MedicationComplianceChart({
title,
compareBaseline = "none",
}: MedicationComplianceChartProps) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
@@ -182,6 +181,7 @@ export function MedicationComplianceChart({
const overlayPrefs = useChartOverlayPrefs("medications");
const showTrendChip = overlayPrefs.prefs.showTrendIndicator;
const showTargetRange = overlayPrefs.prefs.showTargetRange;
const effectiveCompareBaseline = overlayPrefs.prefs.comparisonBaseline;
// v1.4.19 A2 — universal tick-density helper. Pre-fix the medication
// chart drew ONE tick per day, so a 30-day Pixel-5 window painted 30
@@ -320,11 +320,11 @@ export function MedicationComplianceChart({
v1.4.17 when the underlying intake-event window comparison
ships). Without this caption the user toggles Vormonat and
this card just stays static, which reads as a bug. */}
{compareBaseline !== "none" && (
{effectiveCompareBaseline !== "none" && (
<p
className="text-muted-foreground -mt-2 mb-3 text-xs"
data-slot="medication-comparison-na"
data-compare-baseline={compareBaseline}
data-compare-baseline={effectiveCompareBaseline}
>
{t("comparison.notAvailableForCompliance")}
</p>
+41 -34
View File
@@ -289,6 +289,8 @@ export function MoodChart({
const showMA = !mini && overlayPrefs.prefs.showTrendIndicator;
const showTrend = !mini && overlayPrefs.prefs.showTrendArrow;
const showBands = !mini && overlayPrefs.prefs.showTargetRange;
const effectiveCompareBaseline =
!mini && chartKey ? overlayPrefs.prefs.comparisonBaseline : compareBaseline;
// v1.4.19 A2 — viewport-aware tick density helper.
const viewportWidth = useViewportWidth();
@@ -411,7 +413,7 @@ export function MoodChart({
* below renders the dimmed dashed overlay.
*/
const chartDataWithCompare = useMemo<ChartDataPoint[] | undefined>(() => {
if (!chartData || compareBaseline === "none") return chartData;
if (!chartData || effectiveCompareBaseline === "none") return chartData;
if (!data?.entries?.length) return chartData;
const shifted = shiftDailySeriesForward(
@@ -419,7 +421,7 @@ export function MoodChart({
timestamp: dayKeyToTimestamp(entry.date),
score: entry.score,
})),
compareBaseline,
effectiveCompareBaseline,
);
const shiftedByDay = new Map<string, number>();
@@ -437,16 +439,17 @@ export function MoodChart({
}
return point;
});
}, [chartData, compareBaseline, data]);
}, [chartData, effectiveCompareBaseline, data]);
const hasComparisonData = useMemo(() => {
if (compareBaseline === "none" || !chartDataWithCompare) return false;
if (effectiveCompareBaseline === "none" || !chartDataWithCompare)
return false;
return chartDataWithCompare.some((point) =>
typeof point.scoreCompare === "number"
? Number.isFinite(point.scoreCompare)
: false,
);
}, [chartDataWithCompare, compareBaseline]);
}, [chartDataWithCompare, effectiveCompareBaseline]);
// Mirror the activeBucket calculation from health-chart so the chip
// in the header reflects the *same* aggregation chartData used.
@@ -533,30 +536,34 @@ export function MoodChart({
)}
{/* v1.4.16 phase B8 — comparison caption (mood).
v1.4.19 A2 — hidden on mobile to free up the title row. */}
{!mini && compareBaseline !== "none" && hasComparisonData && (
<span
className="text-dracula-purple bg-dracula-purple/10 hidden rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex"
data-slot="chart-compare-caption"
>
{t(
compareBaseline === "lastMonth"
? "comparison.captionLastMonth"
: "comparison.captionLastYear",
)}
</span>
)}
{!mini && compareBaseline !== "none" && !hasComparisonData && (
<span
className="text-muted-foreground bg-muted/40 hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide sm:inline-flex"
data-slot="chart-compare-unavailable"
>
{t(
compareBaseline === "lastMonth"
? "comparison.unavailable.lastMonth"
: "comparison.unavailable.lastYear",
)}
</span>
)}
{!mini &&
effectiveCompareBaseline !== "none" &&
hasComparisonData && (
<span
className="text-dracula-purple bg-dracula-purple/10 hidden rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex"
data-slot="chart-compare-caption"
>
{t(
effectiveCompareBaseline === "lastMonth"
? "comparison.captionLastMonth"
: "comparison.captionLastYear",
)}
</span>
)}
{!mini &&
effectiveCompareBaseline !== "none" &&
!hasComparisonData && (
<span
className="text-muted-foreground bg-muted/40 hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide sm:inline-flex"
data-slot="chart-compare-unavailable"
>
{t(
effectiveCompareBaseline === "lastMonth"
? "comparison.unavailable.lastMonth"
: "comparison.unavailable.lastYear",
)}
</span>
)}
</div>
{!mini && (
<div
@@ -733,11 +740,11 @@ export function MoodChart({
continue;
let delta: string | undefined;
const compareValue =
compareBaseline !== "none" && hoverPoint
effectiveCompareBaseline !== "none" && hoverPoint
? hoverPoint.scoreCompare
: undefined;
if (
compareBaseline !== "none" &&
effectiveCompareBaseline !== "none" &&
typeof compareValue === "number" &&
Number.isFinite(compareValue)
) {
@@ -750,7 +757,7 @@ export function MoodChart({
1,
)}`;
delta = t(
compareBaseline === "lastMonth"
effectiveCompareBaseline === "lastMonth"
? "comparison.deltaVs.lastMonth"
: "comparison.deltaVs.lastYear",
).replace("{delta}", formatted);
@@ -777,7 +784,7 @@ export function MoodChart({
delta,
});
if (
compareBaseline !== "none" &&
effectiveCompareBaseline !== "none" &&
typeof compareValue === "number" &&
Number.isFinite(compareValue)
) {
@@ -838,7 +845,7 @@ export function MoodChart({
uses; mood is a single metric so we only render
one overlay line. Suppressed when there's no prior
data via hasComparisonData. */}
{compareBaseline !== "none" && hasComparisonData && (
{effectiveCompareBaseline !== "none" && hasComparisonData && (
<Line
type="monotone"
dataKey="scoreCompare"
+50 -22
View File
@@ -210,21 +210,21 @@ export function TrendCard({
};
return (
<div className="bg-card border-border flex h-full w-full flex-col rounded-xl border p-4 md:p-6">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
<div className="bg-card border-border flex h-full w-full min-w-0 flex-col rounded-xl border p-4 md:p-6">
<div className="flex min-w-0 items-start justify-between gap-2">
<span className="text-muted-foreground min-w-0 text-xs leading-snug font-medium tracking-wide break-words uppercase">
{label}
</span>
<Icon className="text-muted-foreground h-4 w-4" />
<Icon className="text-muted-foreground h-4 w-4 shrink-0" />
</div>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-bold tracking-tight tabular-nums">
<div className="mt-2 flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
<span className="min-w-0 text-3xl leading-none font-bold tracking-tight [overflow-wrap:anywhere] tabular-nums">
{latest !== null ? renderPair(latest, secondary?.latest) : "—"}
</span>
<span className="text-muted-foreground text-sm tabular-nums">
<span className="text-muted-foreground text-sm [overflow-wrap:anywhere] tabular-nums">
{unit}
</span>
{slope30 && <TrendIcon className={`h-4 w-4 ${trendColor}`} />}
{slope30 && <TrendIcon className={`h-4 w-4 shrink-0 ${trendColor}`} />}
</div>
{/* v1.4.16 phase B8 — comparison delta callout. Sits on its own
line below the latest value so the tile stays scannable on
@@ -246,7 +246,7 @@ export function TrendCard({
>
<span
className={cn(
"text-xs font-medium tabular-nums",
"inline-block max-w-full text-xs leading-snug font-medium [overflow-wrap:anywhere] tabular-nums",
comparisonDeltaColor,
)}
data-slot="tile-compare-delta"
@@ -266,14 +266,17 @@ export function TrendCard({
</div>
)}
<TooltipProvider>
<div className="text-muted-foreground mt-auto flex gap-3 pt-1 text-xs">
<span>
<div className="text-muted-foreground mt-auto flex min-w-0 flex-wrap gap-x-3 gap-y-1 pt-1 text-xs leading-snug">
<span className="max-w-full min-w-0 [overflow-wrap:anywhere]">
{t(avg7LabelKey)}:{" "}
{avg7Hint ? (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn("font-medium tabular-nums", avg7ColorClass)}
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avg7ColorClass,
)}
>
{avg7 !== null ? renderPair(avg7, secondary?.avg7) : "—"}
</span>
@@ -283,13 +286,21 @@ export function TrendCard({
</TooltipContent>
</Tooltip>
) : (
<span className={cn("font-medium tabular-nums", avg7ColorClass)}>
<span
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avg7ColorClass,
)}
>
{avg7 !== null ? renderPair(avg7, secondary?.avg7) : "—"}
</span>
)}
{trend7Delta != null && (
<span
className={cn("ml-1 font-medium tabular-nums", deltaColor)}
className={cn(
"ml-1 font-medium [overflow-wrap:anywhere] tabular-nums",
deltaColor,
)}
data-slot="trend7-delta"
aria-label={`7-day trend ${formatDelta(trend7Delta)}`}
>
@@ -297,13 +308,16 @@ export function TrendCard({
</span>
)}
</span>
<span>
<span className="max-w-full min-w-0 [overflow-wrap:anywhere]">
{t("charts.avg30dShort")}:{" "}
{avg30Hint ? (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn("font-medium tabular-nums", avg30ColorClass)}
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avg30ColorClass,
)}
>
{avg30 !== null ? renderPair(avg30, secondary?.avg30) : "—"}
</span>
@@ -313,7 +327,12 @@ export function TrendCard({
</TooltipContent>
</Tooltip>
) : (
<span className={cn("font-medium tabular-nums", avg30ColorClass)}>
<span
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avg30ColorClass,
)}
>
{avg30 !== null ? renderPair(avg30, secondary?.avg30) : "—"}
</span>
)}
@@ -328,10 +347,16 @@ export function TrendCard({
combined secondary row below so the tile keeps the same
vertical density as every other trend card. */}
{avgAllTime !== undefined && (
<span data-slot="trend-card-all-time" className="hidden sm:inline">
<span
data-slot="trend-card-all-time"
className="hidden max-w-full min-w-0 [overflow-wrap:anywhere] sm:inline"
>
{t(avgAllTimeLabelKey)}:{" "}
<span
className={cn("font-medium tabular-nums", avgAllTimeColorClass)}
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avgAllTimeColorClass,
)}
>
{avgAllTime !== null ? formatValue(avgAllTime) : "—"}
</span>
@@ -346,12 +371,15 @@ export function TrendCard({
{avgAllTime !== undefined && (
<div
data-slot="trend-card-secondary-mobile"
className="text-muted-foreground mt-1 text-xs sm:hidden"
className="text-muted-foreground mt-1 min-w-0 text-xs leading-snug [overflow-wrap:anywhere] sm:hidden"
>
<span>
{t(avgAllTimeLabelKey)}:{" "}
<span
className={cn("font-medium tabular-nums", avgAllTimeColorClass)}
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avgAllTimeColorClass,
)}
>
{avgAllTime !== null ? formatValue(avgAllTime) : "—"}
</span>
@@ -361,7 +389,7 @@ export function TrendCard({
<span aria-hidden="true"> · </span>
<span
className={cn(
"font-medium tabular-nums",
"font-medium [overflow-wrap:anywhere] tabular-nums",
comparisonDeltaColor,
)}
data-slot="tile-compare-delta-mobile"
@@ -172,7 +172,7 @@ export function CoachDrawer({
}
onOpenChange(next);
},
[onOpenChange],
[onOpenChange, setInputValue],
);
const { data: conversation } = useCoachConversation(currentConversationId);
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import {
Bot,
@@ -69,7 +69,10 @@ export function MessageThread({
const { data: coachPrefs } = useCoachPrefs();
const evidenceDefaultOpen = coachPrefs?.showEvidenceByDefault ?? false;
const messages: CoachMessageDTO[] = conversation?.messages ?? [];
const messages: CoachMessageDTO[] = useMemo(
() => conversation?.messages ?? [],
[conversation?.messages],
);
// v1.4.20.1 — once the SSE stream emits `done`, the route's
// invalidate-then-refetch pulls the persisted assistant message into
// `conversation.messages`. The streaming bubble was still rendering
@@ -459,7 +459,7 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
className="h-10 w-10"
onClick={() => startEdit(m)}
aria-label={t("common.edit")}
>
@@ -536,6 +536,7 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
<Pencil className="h-3.5 w-3.5" />
</Button>
<DeleteButton
className="h-10 w-10"
onConfirm={() => deleteMutation.mutate(m.id)}
/>
</div>
@@ -774,7 +775,13 @@ function SortableHead({
);
}
function DeleteButton({ onConfirm }: { onConfirm: () => void }) {
function DeleteButton({
onConfirm,
className = "h-8 w-8",
}: {
onConfirm: () => void;
className?: string;
}) {
const { t } = useTranslations();
return (
<AlertDialog>
@@ -782,7 +789,7 @@ function DeleteButton({ onConfirm }: { onConfirm: () => void }) {
<Button
variant="ghost"
size="icon"
className="text-destructive h-8 w-8"
className={`text-destructive ${className}`}
aria-label={t("common.delete")}
>
<Trash2 className="h-3.5 w-3.5" />
@@ -664,8 +664,9 @@ function MoodLogCard({
<form onSubmit={handleSave} className="space-y-3">
<div>
<Label>{t("settings.moodLogUrl")}</Label>
<Label htmlFor="moodlog-url">{t("settings.moodLogUrl")}</Label>
<Input
id="moodlog-url"
type="url"
placeholder={t("settings.moodLogUrlPlaceholder")}
value={url}
@@ -673,8 +674,11 @@ function MoodLogCard({
/>
</div>
<div>
<Label>{t("settings.moodLogApiKey")}</Label>
<Label htmlFor="moodlog-api-key">
{t("settings.moodLogApiKey")}
</Label>
<PasswordInput
id="moodlog-api-key"
placeholder={
status?.configured
? t("settings.withingsCredentialsSavedPlaceholder")
@@ -705,9 +709,12 @@ function MoodLogCard({
<div className="space-y-3 border-t pt-3">
{status.webhookSecret && (
<div>
<Label>{t("settings.moodLogWebhookSecret")}</Label>
<Label htmlFor="moodlog-webhook-secret">
{t("settings.moodLogWebhookSecret")}
</Label>
<div className="flex gap-2">
<Input
id="moodlog-webhook-secret"
value={status.webhookSecret}
readOnly
className="font-mono text-xs"
@@ -54,9 +54,21 @@ export function NotificationsSection() {
<NotificationStatusCard />
{showTelegram && <TelegramCard isAuthenticated={isAuthenticated} />}
{showNtfy && <NtfyCard isAuthenticated={isAuthenticated} />}
{showWebPush && <WebPushCard />}
{showTelegram && (
<div id="telegram">
<TelegramCard isAuthenticated={isAuthenticated} />
</div>
)}
{showNtfy && (
<div id="ntfy">
<NtfyCard isAuthenticated={isAuthenticated} />
</div>
)}
{showWebPush && (
<div id="web-push">
<WebPushCard />
</div>
)}
</section>
);
}
@@ -244,11 +244,25 @@ describe("resolveDashboardLayout() — chartOverlayPrefs (v1.4.18)", () => {
showTrendIndicator: true,
showTrendArrow: false,
showTargetRange: true,
comparisonBaseline: "lastMonth",
},
weight: {
showTrendIndicator: false,
showTrendArrow: true,
showTargetRange: false,
comparisonBaseline: "none",
},
bmi: {
showTrendIndicator: true,
showTrendArrow: true,
showTargetRange: true,
comparisonBaseline: "lastYear",
},
bodyFat: {
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: true,
comparisonBaseline: "none",
},
},
};
@@ -259,11 +273,25 @@ describe("resolveDashboardLayout() — chartOverlayPrefs (v1.4.18)", () => {
showTrendIndicator: true,
showTrendArrow: false,
showTargetRange: true,
comparisonBaseline: "lastMonth",
},
weight: {
showTrendIndicator: false,
showTrendArrow: true,
showTargetRange: false,
comparisonBaseline: "none",
},
bmi: {
showTrendIndicator: true,
showTrendArrow: true,
showTargetRange: true,
comparisonBaseline: "lastYear",
},
bodyFat: {
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: true,
comparisonBaseline: "none",
},
});
});
@@ -277,6 +305,7 @@ describe("resolveDashboardLayout() — chartOverlayPrefs (v1.4.18)", () => {
showTrendIndicator: "yes",
showTrendArrow: 1,
showTargetRange: null,
comparisonBaseline: "nextWeek",
},
},
};
@@ -285,6 +314,7 @@ describe("resolveDashboardLayout() — chartOverlayPrefs (v1.4.18)", () => {
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: false,
comparisonBaseline: "none",
});
});
@@ -297,11 +327,13 @@ describe("resolveDashboardLayout() — chartOverlayPrefs (v1.4.18)", () => {
showTrendIndicator: true,
showTrendArrow: false,
showTargetRange: false,
comparisonBaseline: "none",
},
not_a_real_chart: {
showTrendIndicator: true,
showTrendArrow: true,
showTargetRange: true,
comparisonBaseline: "lastMonth",
},
},
};
+25 -1
View File
@@ -81,7 +81,7 @@ describe("requireAuth — Bearer token path", () => {
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-1",
userId: "user-1",
permissions: ["medication:ingest"],
permissions: ["*"],
revoked: false,
expiresAt,
} as never);
@@ -198,6 +198,30 @@ describe("requireAuth — Bearer token path", () => {
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("rejects a scoped Bearer token when the route did not declare a scope", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(`Bearer ${RAW_TOKEN}`);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-5",
userId: "user-1",
permissions: ["medication:ingest"],
revoked: false,
expiresAt: null,
} as never);
await expect(requireAuth()).rejects.toMatchObject({
statusCode: 403,
});
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.failure",
expect.objectContaining({
details: expect.objectContaining({ reason: "scope_required" }),
}),
);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("returns 401 when neither cookie nor Bearer is present", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(null);
+2 -4
View File
@@ -1,4 +1,4 @@
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
import type { AIProvider, CompletionResult } from "./types";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { CodexClient } from "./codex-client";
@@ -19,9 +19,7 @@ const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
class NoProvider implements AIProvider {
readonly type = "none" as const;
async generateCompletion(
_params: CompletionParams,
): Promise<CompletionResult> {
async generateCompletion(): Promise<CompletionResult> {
throw new Error(
"No AI provider configured. Connect ChatGPT or set an API key in settings.",
);
+14 -1
View File
@@ -222,9 +222,22 @@ async function authenticateBearer(
// adopt requireAuth("scope:name"), the wildcard handling here keeps
// the iOS app working while narrower-scoped tokens (e.g. ["medication:
// ingest"]) get correctly 403'd.
const hasWildcardPermission = apiToken.permissions.includes("*");
if (!requiredPermission && !hasWildcardPermission) {
auditLog("auth.bearer.failure", {
userId: apiToken.userId,
details: {
reason: "scope_required",
tokenId: apiToken.id,
},
}).catch(() => {});
throw new HttpError(403, "Insufficient permissions");
}
if (
requiredPermission &&
!apiToken.permissions.includes("*") &&
!hasWildcardPermission &&
!apiToken.permissions.includes(requiredPermission)
) {
auditLog("auth.bearer.failure", {
+65
View File
@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const cookieState = vi.hoisted(() => ({
sessionId: undefined as string | undefined,
delete: vi.fn(),
set: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
session: {
findUnique: vi.fn(),
deleteMany: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: () => undefined,
}));
vi.mock("next/headers", () => ({
cookies: vi.fn(async () => ({
get: (name: string) =>
name === "healthlog_session" && cookieState.sessionId
? { value: cookieState.sessionId }
: undefined,
set: cookieState.set,
delete: cookieState.delete,
})),
}));
import { prisma } from "@/lib/db";
import { getSession } from "../session";
beforeEach(() => {
vi.resetAllMocks();
cookieState.sessionId = "sess-expired";
});
describe("getSession", () => {
it("swallows expired-session delete races and clears cookies", async () => {
vi.mocked(prisma.session.findUnique).mockResolvedValue({
id: "sess-expired",
expiresAt: new Date(Date.now() - 1000),
user: { id: "user-1" },
} as never);
vi.mocked(prisma.session.deleteMany).mockRejectedValue(
new Error("already deleted") as never,
);
await expect(getSession()).resolves.toBeNull();
expect(prisma.session.deleteMany).toHaveBeenCalledWith({
where: { id: "sess-expired" },
});
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding");
});
});
+3 -1
View File
@@ -111,7 +111,9 @@ export async function getSession(): Promise<{
if (!session || session.expiresAt < new Date()) {
if (session) {
await prisma.session.delete({ where: { id: sessionId } });
await prisma.session
.deleteMany({ where: { id: sessionId } })
.catch(() => {});
}
cookieStore.delete(SESSION_COOKIE);
cookieStore.delete(ONBOARDING_COOKIE);
+9
View File
@@ -112,9 +112,13 @@ export function isComparisonBaseline(
export const CHART_OVERLAY_KEYS = [
"bp",
"weight",
"bmi",
"pulse",
"bodyFat",
"mood",
"medications",
"sleep",
"steps",
] as const;
export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number];
@@ -122,12 +126,14 @@ export interface ChartOverlayPrefs {
showTrendIndicator: boolean;
showTrendArrow: boolean;
showTargetRange: boolean;
comparisonBaseline: ComparisonBaseline;
}
export const DEFAULT_CHART_OVERLAY_PREFS: ChartOverlayPrefs = {
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: false,
comparisonBaseline: "none",
};
export type ChartOverlayPrefsMap = Partial<
@@ -145,6 +151,9 @@ function coerceChartOverlayPrefs(value: unknown): ChartOverlayPrefs {
showTrendIndicator: candidate.showTrendIndicator === true,
showTrendArrow: candidate.showTrendArrow === true,
showTargetRange: candidate.showTargetRange === true,
comparisonBaseline: isComparisonBaseline(candidate.comparisonBaseline)
? candidate.comparisonBaseline
: "none",
};
}
+1 -1
View File
@@ -151,7 +151,7 @@ export function loadApnsConfig(): ApnsConfig | null {
signingKey = inlineKey.replace(/\\n/g, "\n");
} else {
try {
signingKey = readFileSync(keyFile, "utf8");
signingKey = readFileSync(/* turbopackIgnore: true */ keyFile, "utf8");
} catch (err) {
const message = err instanceof Error ? err.message : "read_failed";
getEvent()?.addWarning(`APNs key file read failed: ${message}`);
+10 -3
View File
@@ -90,6 +90,15 @@ const LEGACY_ADMIN_ANCHORS: Record<string, string> = {
"/admin/section-danger-zone": "/admin/danger-zone",
};
function generateNonce(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16));
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
@@ -178,9 +187,7 @@ export function proxy(request: NextRequest) {
// 128 bits of random, base64-encoded. randomUUID().toString() only carries
// ~122 bits and has a predictable structure that base64-encodes to a
// partially guessable pattern.
const nonce = Buffer.from(
crypto.getRandomValues(new Uint8Array(16)),
).toString("base64");
const nonce = generateNonce();
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
requestHeaders.set("x-request-id", requestId);