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

+4 -3
View File
@@ -216,9 +216,10 @@ MEDs, all LOWs, S-05 simplify, pre-existing `coach-prefs.test.ts` mock
issue, sandbox `git commit` no-op).
Test deltas: W5 ended at 2223 unit. Session A added the `revoke.test.ts`
+ `apns-dispatch` device tests landing at 2227. Session E ended at 2235.
Session F added the forged-X-Device-Id regression — final at 2236 unit
(+45 across the W5+W6 reconcile arc).
- `apns-dispatch` device tests landing at 2227. Session E ended at 2235.
Session F added the forged-X-Device-Id regression — final at 2236 unit
(+45 across the W5+W6 reconcile arc).
## Wave 7 — Release v1.4.23 (next)
+15 -15
View File
@@ -6,13 +6,13 @@ six-review brief into shippable develop-branch state.
## Per-session SHA chain
| Session | Scope | Commit SHAs |
| --- | --- | --- |
| A | CRITICAL + HIGH 1-3 | `5486507`, `13977bc`, `d5202e5`, `a2dfc5e` |
| B | HIGH 4 + simplify (S-02 → S-04) + HIGH 6 | `3ee6dab`, `dfffe6b`, `d25e50e` |
| D | HIGH 5 (analytics chunked aggregate landed in W5; D session re-confirmed against the W6 senior-dev brief) | `deadc73` |
| E | HIGH 7 (Coach feedback referencing `coach_messages`, plaintext column drop) | `650f150` |
| F | MED cluster (Sec-MED-2 Coolify scrub, Sr-MED-5 isCurrent device-id, SLEEP_DURATION migration verification note) + planning record | `1a46bfe` (MED) + this commit (planning) |
| Session | Scope | Commit SHAs |
| ------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
| A | CRITICAL + HIGH 1-3 | `5486507`, `13977bc`, `d5202e5`, `a2dfc5e` |
| B | HIGH 4 + simplify (S-02 → S-04) + HIGH 6 | `3ee6dab`, `dfffe6b`, `d25e50e` |
| D | HIGH 5 (analytics chunked aggregate landed in W5; D session re-confirmed against the W6 senior-dev brief) | `deadc73` |
| E | HIGH 7 (Coach feedback referencing `coach_messages`, plaintext column drop) | `650f150` |
| F | MED cluster (Sec-MED-2 Coolify scrub, Sr-MED-5 isCurrent device-id, SLEEP_DURATION migration verification note) + planning record | `1a46bfe` (MED) + this commit (planning) |
## Counts
@@ -28,14 +28,14 @@ six-review brief into shippable develop-branch state.
## Test count delta
| Snapshot | Unit count |
| --- | --- |
| W5 close (commit `72829b1`) | 2223 |
| Session A close (CRIT + HIGH 1-3 added device + revoke tests) | 2227 |
| Session B close (HIGH 4 + simplify + HIGH 6) | ~2229 |
| Session D close (HIGH 5 reconfirm) | ~2229 |
| Session E close (HIGH 7 + coach-feedback updates) | 2235 |
| Session F close (forged X-Device-Id regression) | **2236** |
| Snapshot | Unit count |
| ------------------------------------------------------------- | ---------- |
| W5 close (commit `72829b1`) | 2223 |
| Session A close (CRIT + HIGH 1-3 added device + revoke tests) | 2227 |
| Session B close (HIGH 4 + simplify + HIGH 6) | ~2229 |
| Session D close (HIGH 5 reconfirm) | ~2229 |
| Session E close (HIGH 7 + coach-feedback updates) | 2235 |
| Session F close (forged X-Device-Id regression) | **2236** |
Net delta over the W6 reconcile arc: **+13 unit tests**.
+1 -1
View File
@@ -12,7 +12,7 @@ without re-reading the full review pack.
the settings cog) duplicates intent. Defer to v1.4.24 once W5 H7
surfaces enough thumbs data to know whether per-user prompt prefs
drift from per-message ratings. Source: `phase-W6-v1423-design-review.md`
+ `phase-W6-v1423-product-lead-review.md`.
- `phase-W6-v1423-product-lead-review.md`.
## Statistics
+139 -14
View File
@@ -1,5 +1,130 @@
# Changelog
## [1.4.24] — 2026-05-12
Security + accessibility hardening release. Closes the highest-priority
findings from a focused security audit of the v1.4.23 release: a Bearer
token that omits a scope no longer authenticates routes which never
declared one, the public Umami proxy is rate-limited, the proxy nonce
is Edge-runtime portable, a session-expiry delete race can no longer
500 a parallel request, the import-validation envelope now matches the
standard error contract, and the runtime Docker image installs Prisma
7.8 alongside pinned worker dependencies. Accessibility passes on the
notifications surface, admin shell, chart overlays and notification
settings deep-link anchors round out the user-facing work.
### Security
- **Bearer auth is fail-closed when a route declares no scope.**
`requireAuth()` previously let any non-revoked, non-expired API token
through whenever a handler omitted the `requiredPermission` argument.
A narrowly-scoped token (e.g. `["medication:ingest"]`) could therefore
reach every handler that called `requireAuth()` bare — including
account-sensitive routes such as `/api/auth/profile`,
`/api/auth/password`, `/api/withings/credentials`,
`/api/settings/data`, `/api/export/full-backup` and `/api/tokens`.
The Bearer path now throws `HttpError(403)` with audit reason
`scope_required` unless the token carries the `["*"]` wildcard
(the iOS app login token), preserving the existing session-cookie
path and the explicit-scope path (`/api/ingest/medications`). The
admin surface is unaffected — `requireAdmin()` was already
cookie-only.
- **Public Umami analytics proxy now rate-limits by client IP.**
`POST /api/send` forwards browser analytics events to the configured
Umami origin. The SSRF allow-list and 64 KB body cap were already in
place, but the endpoint itself was unbounded. Added a 120-events-per-
minute-per-IP gate using the existing `checkRateLimit` infrastructure;
abuse returns the standard `429` envelope without invoking the
upstream fetch.
- **Edge-runtime portable nonce generation in the proxy.** The proxy
previously assembled the CSP nonce via `Buffer.from(...)`, which is
not guaranteed in the Edge runtime. Switched to
`btoa(String.fromCharCode(...new Uint8Array(16)))`. Same 128 bits of
entropy, Node-and-Edge compatible without a runtime declaration.
- **Session-expiry delete race no longer 500s a parallel request.**
`getSession()` reaped expired session rows with `prisma.session
.delete()`; two requests arriving at the same expired session would
race and the loser would surface a 500. Replaced with `deleteMany` +
a catch-all guard, matching the discipline already used in
`destroySession()`.
### Fixed
- **`POST /api/import` returns the standard `{ data: null, error }`
envelope on validation failure.** The handler previously returned
the validation message inside `apiSuccess(...)` with status 422,
which clients could not distinguish from a successful import.
- **Runtime Docker image pins Prisma 7.8 + worker dependencies.** The
multi-stage image was installing `prisma@7.4.0`, `@prisma/engines
@7.4.0`, unpinned `pg-boss@12`, `@prisma/adapter-pg@7` and `pg@8`
into the worker prefix while the app code shipped Prisma 7.8 from
the lockfile. Pinned to `prisma@7.8`, `@prisma/engines@7.8`,
`pg-boss@12.18`, `@prisma/adapter-pg@7.8` and `pg@8.20` so
migration engine and generated client cannot drift across a deploy.
- **Notifications page surfaces a load error instead of a silent
"no channels" empty state.** `useQuery` failures now render an
alert with `role="alert"` and `notifications.loadError` copy
(EN/DE), so a transient 500 on `/api/notifications/preferences`
stops looking like a misconfiguration.
### Accessibility
- **Notification-preference switches carry accessible names.** Each
switch in the per-event table now exposes an `aria-label` combining
event-type label and channel label; the mobile per-event layout pairs
each `<Switch>` with its `<Label>` via `htmlFor`/`id` so screen
readers announce the channel a toggle controls.
- **Mobile admin navigation surfaces an Overview link.** The mobile
admin section pill-rail prepended an Overview chip pointing at
`/admin`, matching the desktop sidebar root and unblocking users
who land on a sub-section without a path back.
- **Notification settings deep-link anchors land on the right card.**
Onboarding and admin-driven deep links into `#telegram`, `#ntfy`
and `#web-push` now resolve to the corresponding cards in
`NotificationsSection` instead of falling back to top-of-page.
- **Chart-overlay comparison-baseline toggles expose `aria-pressed`.**
The new per-chart comparison-baseline selector renders three
pill buttons (none / last month / last year) with the correct
pressed-state semantics for assistive tech.
### Changed
- **Per-chart `comparisonBaseline` overlay preference.** The
comparison overlay is now configurable per chart from the existing
overlay-controls popover instead of being a single global toggle.
`ChartOverlayPrefs` adds a `comparisonBaseline: "none" | "lastMonth"
| "lastYear"` field (defaults to `"none"`); a per-chart selection
overrides the dashboard-level default for that chart only.
`ChartOverlayKey` is extended with `bmi`, `bodyFat`, `sleep` and
`steps`, mirroring the chart surfaces already on the dashboard.
- **Trend-card layout tolerates long labels and long values.**
`TrendCard` switches to `min-w-0` + `flex-wrap` containers and
`[overflow-wrap:anywhere]` text utilities so locale labels
("Letzter Wert" / "Resting Heart Rate") and large tabular numbers
no longer push the tile off-axis on narrow viewports.
- **Lint-clean Coach drawer + message thread.** The `useCallback`
dependency on the drawer's reset closure now includes the stable
`setInputValue` setter, and the message thread memoises the
derived `messages` array. Both removed the only two warning-level
hook lints carried over from v1.4.23.
### Tests
- New unit test in `require-auth-bearer.test.ts` proves that a Bearer
token with permissions `["medication:ingest"]` is rejected (403
with audit reason `scope_required`) when the route did not declare
a scope; the existing success case was updated to use the wildcard
scope so the new fail-closed branch does not collide with it.
- New unit suite under `src/lib/auth/__tests__/session.test.ts`
covers the expired-session delete-race path: a rejected `deleteMany`
must still resolve `getSession()` to `null` and clear both the
session and onboarding cookies.
- New unit suite under `src/app/api/send/__tests__/route.test.ts`
covers the rate-limited Umami proxy: a 429 short-circuits the
upstream fetch and a 200 still proxies under quota.
- Extended import-route test asserts the new `apiError` envelope:
`data === null` and a populated `error` for invalid payloads.
## [1.4.23] — 2026-05-11
### Added
@@ -41,15 +166,15 @@
- **OpenAPI 3.1 generator + drift CI gate.** `pnpm openapi:generate`
reads Zod v4 `.meta()` annotations on the existing validation
schemas and emits a byte-stable `docs/api/openapi.yaml` (`zod-openapi`
+ `yaml@^2` with `sortMapEntries: true`). The eight iOS-critical
routes are registered now: `auth/login`, passkey verify,
`auth/refresh`, `measurements` GET + POST + batch, `devices` POST,
and the comprehensive insights bundle. A new `pnpm openapi:check`
CI step diffs generated vs committed; warn-only for v1.4.23 so a
registry oversight on a non-iOS route doesn't red-bar a PR, flips
to hard-fail in v1.4.24. The legacy hand-maintained spec is
preserved at `docs/api/openapi-v1422-legacy.yaml` so iOS DTO
reference doesn't disappear during the incremental migration.
- `yaml@^2` with `sortMapEntries: true`). The eight iOS-critical
routes are registered now: `auth/login`, passkey verify,
`auth/refresh`, `measurements` GET + POST + batch, `devices` POST,
and the comprehensive insights bundle. A new `pnpm openapi:check`
CI step diffs generated vs committed; warn-only for v1.4.23 so a
registry oversight on a non-iOS route doesn't red-bar a PR, flips
to hard-fail in v1.4.24. The legacy hand-maintained spec is
preserved at `docs/api/openapi-v1422-legacy.yaml` so iOS DTO
reference doesn't disappear during the incremental migration.
- **Device-management endpoints for native + web settings.**
`GET /api/auth/me/devices` lists active devices with label, last-
seen timestamp, channels (`web_push` / `apns`), and an `isCurrent`
@@ -77,7 +202,7 @@
endpoint `POST /api/insights/chat/messages/:id/feedback`. A new
admin section `/admin/coach-feedback` renders helpful-rate buckets
by (promptVersion, tone, verbosity) and gets a sidebar entry
+ EN/DE i18n bundle.
- EN/DE i18n bundle.
### Changed
@@ -117,7 +242,7 @@
steps, active energy, flights, distance, VO2 max, body temp) as
silent when the snapshot doesn't carry them. No apologetic openers
about missing data. `aiInsightResponseSchema.dailyBriefing
.keyFindings[].sourceMetric` and `trendAnnotations` enums extend
.keyFindings[].sourceMetric` and `trendAnnotations` enums extend
to admit the nine additive HealthKit categories.
- **`medication_schedules.days_of_week` column deployed.** Migration
`0039_medication_schedule_days_of_week` adds the nullable column
@@ -135,7 +260,7 @@
- **Partial unique index enforces `apns_token` global uniqueness.**
App-layer guard catches the cross-user-hijack case; the DB-layer
partial unique index (`CREATE UNIQUE INDEX … WHERE apns_token IS
NOT NULL`) is the defence-in-depth backstop.
NOT NULL`) is the defence-in-depth backstop.
- **Apple Health source badge renders on mobile measurement card.**
Desktop list rendered the chip; the mobile card variant fell back
to source-less prose. Both surfaces now share the same renderer.
@@ -162,8 +287,8 @@
APNs-token layer (409 + dedicated audit reason). Send-side payload
redacts the resolved user + device IDs before logging.
- **Coolify webhook URL scrubbed from the deploy runbook.** Webhook
+ token live in GH secrets; the runbook references the secret
names rather than the raw URL.
- token live in GH secrets; the runbook references the secret
names rather than the raw URL.
- **Coach prose encryption-at-rest restored.** The plaintext
`content` column on `recommendation_feedback` was dropped after
the feedback rows migrated to a `coach_messages` FK. Cypher text
+2 -2
View File
@@ -55,13 +55,13 @@ COPY --from=builder /app/src/generated ./src/generated
RUN mkdir -p /opt/pg-boss && \
cd /opt/pg-boss && \
npm init -y && \
npm install --omit=dev pg-boss@12 @prisma/adapter-pg@7 pg@8
npm install --omit=dev pg-boss@12.18 @prisma/adapter-pg@7.8 pg@8.20
# Install Prisma CLI + engines for runtime migrations (isolated from Next standalone tree)
RUN mkdir -p /opt/prisma-cli && \
cd /opt/prisma-cli && \
npm init -y && \
npm install --omit=dev prisma@7.4.0 @prisma/engines@7.4.0
npm install --omit=dev prisma@7.8 @prisma/engines@7.8
# Entrypoint script (runs migrations, then starts app)
COPY docker-entrypoint.sh ./
+20 -10
View File
@@ -210,7 +210,8 @@ components:
- error
type: object
DeviceRegisterRequest:
description: Native device registration. Re-registering an APNs token belonging to another user returns 409
description:
Native device registration. Re-registering an APNs token belonging to another user returns 409
(cross-user-hijack guard).
properties:
apnsEnvironment:
@@ -353,7 +354,8 @@ components:
type: object
InsightsComprehensiveResponse:
additionalProperties: false
description: AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface
description:
AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface
needs day-level grounding.
properties:
citations:
@@ -707,7 +709,8 @@ openapi: 3.1.0
paths:
/api/auth/login:
post:
description: "Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA
description:
"Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA
prefix) additionally receive a paired access + refresh token."
requestBody:
content:
@@ -755,7 +758,8 @@ paths:
- Auth
/api/auth/me/coach-prefs:
get:
description: Returns the persisted preferences with defaults filled in. Null persisted state resolves to the legacy
description:
Returns the persisted preferences with defaults filled in. Null persisted state resolves to the legacy
v1.4.22 defaults.
responses:
"200":
@@ -771,7 +775,8 @@ paths:
tags:
- Auth
put:
description: Persists the supplied prefs. Body is validated against `CoachPrefs`; missing keys fall back to the
description:
Persists the supplied prefs. Body is validated against `CoachPrefs`; missing keys fall back to the
documented defaults.
requestBody:
content:
@@ -849,7 +854,8 @@ paths:
- Auth
/api/auth/refresh:
post:
description: Reuse of a consumed refresh token revokes every refresh token still active for the originating device
description:
Reuse of a consumed refresh token revokes every refresh token still active for the originating device
(per-device blast radius from v1.4.23). Legacy tokens issued before v1.4.23 with a null deviceId fall back to
revoke-all-for-user.
requestBody:
@@ -873,7 +879,8 @@ paths:
- Auth
/api/devices:
post:
description: Idempotent upsert by `token`. APNs token + environment are paired — supplying one without the other returns
description:
Idempotent upsert by `token`. APNs token + environment are paired — supplying one without the other returns
422. Cross-user re-registration of either identifier returns 409.
requestBody:
content:
@@ -902,7 +909,8 @@ paths:
- Devices
/api/insights/chat/messages/{id}/feedback:
post:
description: Persists a helpful/unhelpful rating for a single Coach reply. Reuses the v1.4.16 RecommendationFeedback
description:
Persists a helpful/unhelpful rating for a single Coach reply. Reuses the v1.4.16 RecommendationFeedback
table via the polymorphic `targetType` column. The aggregator buckets ratings by (promptVersion, tone,
verbosity).
requestBody:
@@ -938,7 +946,8 @@ paths:
- Insights
/api/insights/comprehensive:
get:
description: Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard
description:
Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard
annotations. Strict-schema validated server-side.
responses:
"200":
@@ -1110,7 +1119,8 @@ paths:
- Measurements
/api/measurements/batch:
post:
description: Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h).
description:
Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h).
Per-entry status lets the iOS client advance its sync cursor accurately.
requestBody:
content:
+8 -8
View File
@@ -6,14 +6,14 @@ v1.4.22 is the polish release that closes loops the Insights redesign, the AI Co
## Live state
| Field | Value |
|---|---|
| URL | `https://healthlog.bombeck.io` |
| `/api/version` | `1.4.22` |
| Image digest | `sha256:865154614303fdc362ee3941776f73ec0f60e1f16112ec272a75cbbe28e2cffb` |
| Version transition | 2026-05-10T22:43:50+02:00 (host-side retag fallback) |
| GH release | https://github.com/MBombeck/HealthLog/releases/tag/v1.4.22 |
| Branch model | second release through the `develop` → `main` model (introduced in v1.4.20 F1) |
| Field | Value |
| ------------------ | ------------------------------------------------------------------------------ |
| URL | `https://healthlog.bombeck.io` |
| `/api/version` | `1.4.22` |
| Image digest | `sha256:865154614303fdc362ee3941776f73ec0f60e1f16112ec272a75cbbe28e2cffb` |
| Version transition | 2026-05-10T22:43:50+02:00 (host-side retag fallback) |
| GH release | https://github.com/MBombeck/HealthLog/releases/tag/v1.4.22 |
| Branch model | second release through the `develop` → `main` model (introduced in v1.4.20 F1) |
## Smoke (no session)
+2
View File
@@ -665,6 +665,7 @@
"trendIndicator": "7-Tage-Trend",
"trendArrow": "Trend-Pfeil",
"targetRange": "Zielbereich",
"comparisonBaseline": "Vergleich",
"tooltip": {
"openSettings": "Chart-Overlay-Einstellungen"
}
@@ -2492,6 +2493,7 @@
"title": "Benachrichtigungen",
"eventColumn": "Ereignis",
"subtitle": "Wähle aus, welche Benachrichtigungen du über welchen Kanal erhalten möchtest.",
"loadError": "Benachrichtigungseinstellungen konnten nicht geladen werden.",
"noChannels": "Konfiguriere zuerst einen Benachrichtigungskanal in den Einstellungen.",
"goToSettings": "Zu den Einstellungen",
"channelDisabled": "Deaktiviert",
+2
View File
@@ -665,6 +665,7 @@
"trendIndicator": "7-day trend",
"trendArrow": "Trend arrow",
"targetRange": "Target range",
"comparisonBaseline": "Comparison",
"tooltip": {
"openSettings": "Chart overlay settings"
}
@@ -2492,6 +2493,7 @@
"title": "Notifications",
"eventColumn": "Event",
"subtitle": "Choose which notifications you want to receive through which channel.",
"loadError": "Notification preferences could not be loaded.",
"noChannels": "Configure a notification channel in Settings first.",
"goToSettings": "Go to Settings",
"channelDisabled": "Disabled",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.4.23",
"version": "1.4.24",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
-4
View File
@@ -22,11 +22,9 @@ function main(): void {
});
const committed = readFileSync(SPEC_PATH, "utf8");
if (generated === committed) {
// eslint-disable-next-line no-console
console.log("OpenAPI spec in sync with source schemas.");
return;
}
// eslint-disable-next-line no-console
console.error(
"OpenAPI spec drift detected. Run `pnpm openapi:generate` and commit the result.",
);
@@ -37,7 +35,6 @@ function main(): void {
const max = Math.max(genLines.length, onDiskLines.length);
for (let i = 0; i < max; i++) {
if (genLines[i] !== onDiskLines[i]) {
// eslint-disable-next-line no-console
console.error(
`Line ${i + 1}:\n generated: ${JSON.stringify(genLines[i] ?? "")}\n committed: ${JSON.stringify(onDiskLines[i] ?? "")}`,
);
@@ -46,7 +43,6 @@ function main(): void {
let shown = 1;
for (let j = i + 1; j < max && shown < 5; j++) {
if (genLines[j] !== onDiskLines[j]) {
// eslint-disable-next-line no-console
console.error(
`Line ${j + 1}:\n generated: ${JSON.stringify(genLines[j] ?? "")}\n committed: ${JSON.stringify(onDiskLines[j] ?? "")}`,
);
-1
View File
@@ -28,7 +28,6 @@ function main(): void {
lineWidth: 120,
});
writeFileSync(SPEC_PATH, yaml, "utf8");
// eslint-disable-next-line no-console
console.log(`Wrote ${SPEC_PATH} (${yaml.length} bytes)`);
}
@@ -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);
@@ -100,7 +100,10 @@ describe("GET /api/analytics — sleep-stage aggregation", () => {
// route should surface a single Measurement-summary datapoint
// (~480 minutes total) plus a per-stage breakdown.
const baseDate = new Date("2026-05-09T01:00:00.000Z");
const stages: Array<{ stage: "IN_BED" | "CORE" | "DEEP" | "REM"; min: number }> = [
const stages: Array<{
stage: "IN_BED" | "CORE" | "DEEP" | "REM";
min: number;
}> = [
{ stage: "IN_BED", min: 480 },
{ stage: "CORE", min: 220 },
{ stage: "DEEP", min: 90 },
@@ -128,9 +131,9 @@ describe("GET /api/analytics — sleep-stage aggregation", () => {
// the inner handler ignores its arguments. Cast through `unknown`
// because `apiHandler`'s narrowed type signature is the inner
// handler's `()`, not the wrapper's `(NextRequest)`.
const response = await (GET as unknown as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/analytics"),
);
const response = await (
GET as unknown as (req: Request) => Promise<Response>
)(new Request("http://localhost/api/analytics"));
expect(response.status).toBe(200);
const envelope = (await response.json()) as AnalyticsEnvelope;
@@ -173,9 +176,9 @@ describe("GET /api/analytics — sleep-stage aggregation", () => {
// the inner handler ignores its arguments. Cast through `unknown`
// because `apiHandler`'s narrowed type signature is the inner
// handler's `()`, not the wrapper's `(NextRequest)`.
const response = await (GET as unknown as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/analytics"),
);
const response = await (
GET as unknown as (req: Request) => Promise<Response>
)(new Request("http://localhost/api/analytics"));
expect(response.status).toBe(200);
const envelope = (await response.json()) as AnalyticsEnvelope;
+1
View File
@@ -14,6 +14,7 @@ export default defineConfig({
exclude: [
"**/node_modules/**",
"**/dist/**",
"**/.next/**",
"tests/integration/**",
// Playwright E2E suite — driven separately via `pnpm e2e`.
"e2e/**",