Security + accessibility hardening release. Closes the highest-priority findings from a focused security audit of the v1.4.23 release.
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 therequiredPermissionargument. A narrowly-scoped token (e.g.["medication:ingest"]) could therefore reach every handler that calledrequireAuth()bare — including account-sensitive routes such as/api/auth/profile,/api/auth/password,/api/withings/credentials,/api/settings/data,/api/export/full-backupand/api/tokens. The Bearer path now throwsHttpError(403)with audit reasonscope_requiredunless 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/sendforwards 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/minute/IP gate; abuse returns the standard429envelope 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 tobtoa(String.fromCharCode(...new Uint8Array(16))). Same 128 bits of entropy, Node-and-Edge compatible. - Session-expiry delete race no longer 500s a parallel request.
getSession()reaped expired session rows withprisma.session.delete(); two requests arriving at the same expired session would race and the loser would surface a 500. Replaced withdeleteMany+ catch-all guard, matching the discipline already used indestroySession().
Fixed
POST /api/importreturns the standard{ data: null, error }envelope on validation failure. The handler previously returned the validation message insideapiSuccess(...)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, unpinnedpg-boss@12,@prisma/adapter-pg@7andpg@8into the worker prefix while the app code shipped Prisma 7.8 from the lockfile. Pinned toprisma@7.8,@prisma/engines@7.8,pg-boss@12.18,@prisma/adapter-pg@7.8andpg@8.20. - Notifications page surfaces a load error instead of a silent "no channels" empty state.
useQueryfailures now render an alert withrole="alert"andnotifications.loadErrorcopy (EN/DE).
Accessibility
- Notification-preference switches carry accessible names. Each switch in the per-event table now exposes an
aria-labelcombining event-type label and channel label; the mobile per-event layout pairs each<Switch>with its<Label>viahtmlFor/idso 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. - Notification settings deep-link anchors land on the right card. Onboarding and admin-driven deep links into
#telegram,#ntfyand#web-pushnow resolve to the corresponding cards. - Chart-overlay comparison-baseline toggles expose
aria-pressed.
Changed
- Per-chart
comparisonBaselineoverlay preference. The comparison overlay is now configurable per chart from the existing overlay-controls popover instead of being a single global toggle.ChartOverlayPrefsadds acomparisonBaseline: "none" | "lastMonth" | "lastYear"field (defaults to"none"); a per-chart selection overrides the dashboard-level default.ChartOverlayKeyis extended withbmi,bodyFat,sleepandsteps. - Trend-card layout tolerates long labels and long values.
TrendCardswitches tomin-w-0+flex-wrapcontainers and[overflow-wrap:anywhere]so locale labels and large tabular numbers no longer push the tile off-axis on narrow viewports. - Lint-clean Coach drawer + message thread.
useCallbackdependency on the drawer's reset closure now includes the stablesetInputValuesetter; the message thread memoises the derivedmessagesarray.
Tests
- New unit test in
require-auth-bearer.test.tsproves that a Bearer token with permissions["medication:ingest"]is rejected (403 /scope_required) when the route did not declare a scope. - New unit suite under
src/lib/auth/__tests__/session.test.tscovers the expired-session delete-race path. - New unit suite under
src/app/api/send/__tests__/route.test.tscovers the rate-limited Umami proxy. - Extended import-route test asserts the new
apiErrorenvelope.
Verification: pnpm typecheck ✓ — pnpm lint ✓ (0 errors, 0 warnings) — pnpm test ✓ (2244 / 2244) — pnpm format:check ✓.
Known follow-ups (deferred to v1.4.25)
- Admin data wipe (
DELETE /api/admin/data) does not yet coverMoodEntry,UserAchievement,RecommendationFeedback,CoachConversation/Message/Usage,IntegrationStatus,Device. - Backup payload / restore handler are asymmetric: restore clears notification channels and push subscriptions that the backup never carried.
- First-user bootstrap (
POST /api/auth/register) decidesADMINvsUSERbetween two separate Prisma operations without a transactional lock.