From fcda6c0ef669e3a44522185a4359999de68444b8 Mon Sep 17 00:00:00 2001 From: aliceout Date: Thu, 2 Jul 2026 16:45:09 +0300 Subject: [PATCH 01/13] fix(a11y): restore visible keyboard focus ring on bespoke buttons The global reset in ui/theme/utilities.css strips the native focus outline on every diff --git a/packages/web/src/ui/dirk/module/CollapsibleFiltersToggle.tsx b/packages/web/src/ui/dirk/module/CollapsibleFiltersToggle.tsx index 11c2b891..b474b25a 100644 --- a/packages/web/src/ui/dirk/module/CollapsibleFiltersToggle.tsx +++ b/packages/web/src/ui/dirk/module/CollapsibleFiltersToggle.tsx @@ -1,5 +1,7 @@ import { useState, type ReactNode } from 'react'; +import { FOCUS_RING } from '@/lib/utils'; + /** * Mobile-only « + Filtres » disclosure — folds a module's filter * sections so they don't squat at the bottom of the page when the @@ -43,7 +45,7 @@ export default function CollapsibleFiltersToggle({ type="button" onClick={() => setOpen((v) => !v)} aria-expanded={open} - className="text-[12px] text-muted transition-colors hover:text-ink" + className={`rounded-sm text-[12px] text-muted transition-colors hover:text-ink ${FOCUS_RING}`} > {open ? '− ' : '+ '} {label} diff --git a/packages/web/src/ui/dirk/module/FilterChip.tsx b/packages/web/src/ui/dirk/module/FilterChip.tsx index 3bc3474f..aa2a7648 100644 --- a/packages/web/src/ui/dirk/module/FilterChip.tsx +++ b/packages/web/src/ui/dirk/module/FilterChip.tsx @@ -1,4 +1,4 @@ -import { cn } from '@/lib/utils'; +import { cn, FOCUS_RING } from '@/lib/utils'; interface FilterChipProps { active: boolean; @@ -25,6 +25,7 @@ export default function FilterChip({ active, onClick, label, count }: FilterChip aria-pressed={active} className={cn( 'cursor-pointer rounded px-2.5 py-1 text-[12px] tabular-nums transition-colors', + FOCUS_RING, active ? 'bg-accent-soft font-semibold text-accent-deep' : 'text-muted hover:bg-bg-2 hover:text-ink', diff --git a/packages/web/src/ui/dirk/module/YearSelector.tsx b/packages/web/src/ui/dirk/module/YearSelector.tsx index d9ed9d1b..7a642463 100644 --- a/packages/web/src/ui/dirk/module/YearSelector.tsx +++ b/packages/web/src/ui/dirk/module/YearSelector.tsx @@ -1,5 +1,5 @@ import { useI18n } from '@/i18n/I18nProvider.jsx'; -import { cn } from '@/lib/utils'; +import { cn, FOCUS_RING } from '@/lib/utils'; import Select from '@/ui/atoms/dirk/Select'; /** @@ -26,6 +26,7 @@ interface Props { const chip = (active: boolean) => cn( 'cursor-pointer rounded px-2.5 py-1 text-[12px] transition-colors', + FOCUS_RING, active ? 'bg-accent-soft font-semibold text-accent-deep' : 'text-muted hover:bg-bg-2 hover:text-ink', diff --git a/packages/web/src/ui/dirk/sidebar/SidebarNav.tsx b/packages/web/src/ui/dirk/sidebar/SidebarNav.tsx index f9bf704e..04cb853e 100644 --- a/packages/web/src/ui/dirk/sidebar/SidebarNav.tsx +++ b/packages/web/src/ui/dirk/sidebar/SidebarNav.tsx @@ -21,7 +21,7 @@ import { type HrtSubview, } from '@/core/store/nodea-store'; import { useI18n } from '@/i18n/I18nProvider.jsx'; -import { cn } from '@/lib/utils'; +import { cn, FOCUS_RING } from '@/lib/utils'; import { useSlidingIndicator } from '@/ui/dirk/use-sliding-indicator'; interface NavItem { @@ -219,6 +219,7 @@ function SidebarItem({ // transparent and only its text / icon colour changes. 'group relative z-10 flex w-full items-center rounded px-2.5 py-[0.4125rem] text-left transition-[background-color,color,transform] duration-200', 'text-[13.5px] text-ink-soft', + FOCUS_RING, drawer ? '' : collapsed ? 'justify-center' : 'justify-center lg:justify-start', active ? // Deep-green text, with the icon kept at full accent (see below) @@ -292,6 +293,7 @@ function LibrarySubNav({ aria-current={active ? 'page' : undefined} className={cn( 'block w-full rounded px-2 py-[0.275rem] text-left text-[12.5px] transition-colors', + FOCUS_RING, active ? 'bg-bg font-medium text-ink' : 'text-muted hover:bg-bg hover:text-ink', @@ -351,6 +353,7 @@ function HrtSubNav({ aria-current={active ? 'page' : undefined} className={cn( 'block w-full rounded px-2 py-[0.275rem] text-left text-[12.5px] transition-colors', + FOCUS_RING, active ? 'bg-bg font-medium text-ink' : 'text-muted hover:bg-bg hover:text-ink', From 457ad1dd3f9cfb30fa26f57a491d0f88d26e2321 Mon Sep 17 00:00:00 2001 From: aliceout Date: Thu, 2 Jul 2026 16:50:13 +0300 Subject: [PATCH 02/13] fix(auth): derive recovery decoy blobs deterministically to close enumeration oracle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /auth/recover-kek/start returns fake wrap blobs for unknown emails so the response shape can't be used to enumerate accounts. But the decoys used randomBytes and changed on every call, while a real account's wrappedKekRecovery is a stable DB column — so two calls with the same email revealed known (stable) vs unknown (varies). This is the same oracle closed for userId in v2.8.0, still open on the blobs. Derive both decoy blobs via HMAC-SHA-256 under COOKIE_SECRET (same shield label as deriveFakeUserId), stable per email, preserving the 48/12-byte shapes. Add a route test asserting the decoys are identical across calls for one email and distinct across emails. Co-Authored-By: Claude Opus 4.8 --- packages/api/src/routes/auth-recovery.ts | 56 ++++++++++++++++++--- packages/api/src/test/auth-recovery.test.ts | 16 ++++++ 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/packages/api/src/routes/auth-recovery.ts b/packages/api/src/routes/auth-recovery.ts index 22a95417..68cb3f56 100644 --- a/packages/api/src/routes/auth-recovery.ts +++ b/packages/api/src/routes/auth-recovery.ts @@ -1,5 +1,5 @@ import { eq } from 'drizzle-orm'; -import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; +import { createHmac, timingSafeEqual } from 'node:crypto'; import { RecoverKekFinishBodySchema, RecoverKekStartBodySchema, @@ -354,16 +354,23 @@ interface FakeBlobs { } /** - * Generate fresh random base64-shaped blobs of the right - * lengths so the response for an unknown email is byte-shape- - * indistinguishable from a real one. The client will fail to - * unwrap them with whatever recovery code they typed — no - * server log of the mismatch. + * Derive base64-shaped decoy blobs of the right lengths so the + * response for an unknown email is byte-shape-indistinguishable + * from a real one. The client fails to unwrap them with whatever + * recovery code they typed — no server log of the mismatch. * * Real `wrappedKekRecovery` is AES-GCM(KEK, …) → 32-byte KEK + * 16-byte tag = 48 bytes ciphertext → 64 chars base64. * IV is 12 bytes → 16 chars base64. * + * **Decoys MUST be deterministic per email**, for the exact reason + * the userId below is (audit v2.8.0): a real `wrappedKekRecovery` + * is a stable DB column, so a `randomBytes` decoy that differed + * between two calls for the same email would distinguish unknown + * (varies) from known (stable) — re-opening the enumeration oracle + * the userId fix closed. Both blobs are therefore HMAC-derived + * under COOKIE_SECRET (same shield label), stable per email. + * * **userId determinism (audit v2.8.0).** Before, the fake userId * used `randomUUID()` and was therefore different on every call — * an attacker who hit `/recover-kek/start` twice with the same @@ -406,10 +413,43 @@ function deriveFakeUserId(email: string, secret: string): string { ); } +/** + * `length` deterministic bytes for (secret, email, label), via + * HMAC-SHA-256 expanded across a counter when one 32-byte block isn't + * enough. Same inputs → same bytes, so the decoy blobs stay stable + * across repeated calls for a given email (see the anti-enum note above). + */ +function deriveFakeBytes( + email: string, + secret: string, + label: string, + length: number, +): Buffer { + const blocks: Buffer[] = []; + for (let counter = 0; blocks.length * 32 < length; counter++) { + blocks.push( + createHmac('sha256', secret) + .update(RECOVER_ENUM_SHIELD_LABEL) + .update('\x1f') + .update(label) + .update('\x1f') + .update(email) + .update('\x1f') + .update(String(counter)) + .digest(), + ); + } + return Buffer.concat(blocks).subarray(0, length); +} + function fakeRecoveryBlobs(email: string, secret: string): FakeBlobs { return { - wrappedKekRecovery: randomBytes(48).toString('base64'), - wrappedKekRecoveryIv: randomBytes(12).toString('base64'), + wrappedKekRecovery: deriveFakeBytes(email, secret, 'wrappedKekRecovery', 48).toString( + 'base64', + ), + wrappedKekRecoveryIv: deriveFakeBytes(email, secret, 'wrappedKekRecoveryIv', 12).toString( + 'base64', + ), userId: deriveFakeUserId(email, secret), }; } diff --git a/packages/api/src/test/auth-recovery.test.ts b/packages/api/src/test/auth-recovery.test.ts index c50849b8..a0075bd3 100644 --- a/packages/api/src/test/auth-recovery.test.ts +++ b/packages/api/src/test/auth-recovery.test.ts @@ -536,6 +536,22 @@ describe('POST /auth/recover-kek/start (anti-enum)', () => { expect(start.recoverSessionId).toBeTypeOf('string'); }); + it('returns STABLE decoy blobs across calls for one unknown email (anti-enum)', async () => { + // A real user's wrap blobs are a stable DB column. If the decoy + // blobs varied between two calls for the same unknown email, that + // instability alone would distinguish unknown (varies) from known + // (stable) — the enumeration oracle. So the decoys are derived + // deterministically per email and MUST be identical across calls. + const a = await callRecoverStart('ghost-stable@example.com', NEW_PASSWORD); + const b = await callRecoverStart('ghost-stable@example.com', NEW_PASSWORD); + expect(a.wrappedKekRecovery).toBe(b.wrappedKekRecovery); + expect(a.wrappedKekRecoveryIv).toBe(b.wrappedKekRecoveryIv); + expect(a.userId).toBe(b.userId); + // …but distinct per email, so two unknown emails don't collide. + const c = await callRecoverStart('ghost-other@example.com', NEW_PASSWORD); + expect(c.wrappedKekRecovery).not.toBe(a.wrappedKekRecovery); + }); + it('returns the same shape for a known user without a recovery code (anti-enum)', async () => { // Seeded user but no recovery code set up — should be // indistinguishable from the unknown-email branch. From dbb2691f51ff43469415d54c20e965e66644d267 Mon Sep 17 00:00:00 2001 From: aliceout Date: Thu, 2 Jul 2026 16:52:00 +0300 Subject: [PATCH 03/13] fix(account): stop clobbering current-device label with decrypt-failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opportunistic device-label PATCH marked the current session row with { deviceLabelCipher: 'set', deviceLabelIv: 'set' } to avoid a re-PATCH. That setSessions re-ran the decrypt effect, whose guard only checks truthiness — so it tried to decrypt the 'set' sentinel, threw, and overwrote the just-set label with « échec du déchiffrement » until a full page reload. Thread the real { cipher, iv } from encryptMetaString through the PATCH chain and store those instead, so the decrypt effect round-trips back to the hint label and the null-cipher guard still blocks the re-PATCH. Co-Authored-By: Claude Opus 4.8 --- .../flow/Account/components/SessionsCard.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/web/src/app/flow/Account/components/SessionsCard.tsx b/packages/web/src/app/flow/Account/components/SessionsCard.tsx index 75492a47..f4af722a 100644 --- a/packages/web/src/app/flow/Account/components/SessionsCard.tsx +++ b/packages/web/src/app/flow/Account/components/SessionsCard.tsx @@ -133,21 +133,26 @@ export default function SessionsCard() { const hint = parseDeviceLabel(navigator.userAgent); const aad = buildSessionDeviceLabelAAD(user.id); encryptMetaString(hint.label, mainKey.aesKey, aad) - .then(({ cipher, iv }) => - apiPatchCurrentSessionDeviceLabel({ cipher, iv }), - ) - .then(() => { + .then(async ({ cipher, iv }) => { + await apiPatchCurrentSessionDeviceLabel({ cipher, iv }); + return { cipher, iv }; + }) + .then(({ cipher, iv }) => { if (cancelled) return; // Refresh the view : the local label cache for the current // session now points at the just-encrypted hint, and the - // session row's cipher fields can be marked as set so we - // don't try to PATCH again on rerender. + // session row gets the REAL cipher/iv we just wrote. Writing + // the actual blobs (not a 'set' sentinel) matters — this + // setSessions re-runs the decrypt effect above, which would + // choke on a sentinel and clobber the label with « échec du + // déchiffrement » ; with the real blobs it round-trips back to + // hint.label, and the null-cipher guard stops the re-PATCH. setLabels((prev) => new Map(prev).set(current.id, hint.label)); setSessions((prev) => prev ? prev.map((s) => s.id === current.id - ? { ...s, deviceLabelCipher: 'set', deviceLabelIv: 'set' } + ? { ...s, deviceLabelCipher: cipher, deviceLabelIv: iv } : s, ) : prev, From 8f50a2ded2f0eb75f7ecb1cf5f0f289caf5e7803 Mon Sep 17 00:00:00 2001 From: aliceout Date: Thu, 2 Jul 2026 16:56:52 +0300 Subject: [PATCH 04/13] fix(i18n): localise heatmap month + tooltip labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mood, Journal and Homepage heatmaps hardcoded Intl.DateTimeFormat with 'fr-FR' for their month-axis labels (and Mood's cell tooltips), so English users saw « janv./févr. » on the frises regardless of the active language. Route the labels through the shared getMonthNames(language) helper (Journal/Homepage already had language in scope) and thread language into Mood's buildHeatmap, whose two callers pass it from useI18n. Add a test asserting the month labels differ between fr and en. Co-Authored-By: Claude Opus 4.8 --- .../flow/Homepage/components/JournalHeatmap.tsx | 6 +++--- .../app/flow/Homepage/components/MoodBlock.tsx | 6 +++--- packages/web/src/app/flow/Journal/lib/heatmap.ts | 6 +++--- .../web/src/app/flow/Mood/lib/heatmap.test.ts | 12 ++++++++++++ packages/web/src/app/flow/Mood/lib/heatmap.ts | 15 ++++++++++----- packages/web/src/app/flow/Mood/views/Chart.tsx | 10 +++++----- 6 files changed, 36 insertions(+), 19 deletions(-) diff --git a/packages/web/src/app/flow/Homepage/components/JournalHeatmap.tsx b/packages/web/src/app/flow/Homepage/components/JournalHeatmap.tsx index c1e298d4..d36b5eda 100644 --- a/packages/web/src/app/flow/Homepage/components/JournalHeatmap.tsx +++ b/packages/web/src/app/flow/Homepage/components/JournalHeatmap.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import { formatLongDate } from '@/core/i18n/date-format'; +import { formatLongDate, getMonthNames } from '@/core/i18n/date-format'; import { useI18n } from '@/i18n/I18nProvider.jsx'; import { useMediaQuery } from '@/lib/use-media-query'; import Heatmap, { @@ -85,14 +85,14 @@ export default function JournalHeatmap() { }); } - const monthFormatter = new Intl.DateTimeFormat('fr-FR', { month: 'short' }); + const monthNames = getMonthNames(language, 'short'); const labels: HeatmapMonthLabel[] = []; let prevMonth = -1; for (let w = 0; w < weeks; w++) { const monday = new Date(oldestMonday); monday.setDate(oldestMonday.getDate() + w * 7); if (monday.getMonth() !== prevMonth) { - labels.push({ weekIndex: w, label: monthFormatter.format(monday) }); + labels.push({ weekIndex: w, label: monthNames[monday.getMonth()]! }); prevMonth = monday.getMonth(); } } diff --git a/packages/web/src/app/flow/Homepage/components/MoodBlock.tsx b/packages/web/src/app/flow/Homepage/components/MoodBlock.tsx index ff6e0079..770b893f 100644 --- a/packages/web/src/app/flow/Homepage/components/MoodBlock.tsx +++ b/packages/web/src/app/flow/Homepage/components/MoodBlock.tsx @@ -29,15 +29,15 @@ import HomeModuleLink from './HomeModuleLink'; * previous 14-day strip wasn't. */ export default function MoodBlock() { - const { t } = useI18n(); + const { t, language } = useI18n(); const { mood } = useHomepageData(); const isDesktop = useMediaQuery('(min-width: 1024px)'); const weeks = isDesktop ? WEEKS_DESKTOP : WEEKS_MOBILE; const months = isDesktop ? 6 : 4; const { cells, monthLabels } = useMemo( - () => buildHeatmap(null, mood, new Date(), weeks), - [mood, weeks], + () => buildHeatmap(null, mood, new Date(), weeks, language), + [mood, weeks, language], ); const heatmapCells = useMemo>( diff --git a/packages/web/src/app/flow/Journal/lib/heatmap.ts b/packages/web/src/app/flow/Journal/lib/heatmap.ts index 44566658..e453b848 100644 --- a/packages/web/src/app/flow/Journal/lib/heatmap.ts +++ b/packages/web/src/app/flow/Journal/lib/heatmap.ts @@ -2,7 +2,7 @@ import type { HeatmapCellInput, HeatmapMonthLabel, } from '@/ui/dirk/Heatmap'; -import { formatLongDate } from '@/core/i18n/date-format'; +import { formatLongDate, getMonthNames } from '@/core/i18n/date-format'; import { densityToIntensity, type DayDensity } from './day-density'; import { isoDay } from './stats'; @@ -121,14 +121,14 @@ export function buildJournalHeatmap( isos.push(iso); } - const monthFormatter = new Intl.DateTimeFormat('fr-FR', { month: 'short' }); + const monthNames = getMonthNames(language, 'short'); const labels: HeatmapMonthLabel[] = []; let prevMonth = -1; for (let w = 0; w < weeks; w++) { const monday = new Date(oldestMonday); monday.setDate(oldestMonday.getDate() + w * 7); if (monday.getMonth() !== prevMonth) { - labels.push({ weekIndex: w, label: monthFormatter.format(monday) }); + labels.push({ weekIndex: w, label: monthNames[monday.getMonth()]! }); prevMonth = monday.getMonth(); } } diff --git a/packages/web/src/app/flow/Mood/lib/heatmap.test.ts b/packages/web/src/app/flow/Mood/lib/heatmap.test.ts index 2d4eb577..507b83c5 100644 --- a/packages/web/src/app/flow/Mood/lib/heatmap.test.ts +++ b/packages/web/src/app/flow/Mood/lib/heatmap.test.ts @@ -76,4 +76,16 @@ describe('buildHeatmap', () => { expect(m.weekIndex).toBeLessThan(HEATMAP_WEEKS); } }); + + it('localises the month labels to the active language', () => { + const fr = buildHeatmap(null, [], TODAY, HEATMAP_WEEKS, 'fr') + .monthLabels.map((m) => m.label) + .join(','); + const en = buildHeatmap(null, [], TODAY, HEATMAP_WEEKS, 'en') + .monthLabels.map((m) => m.label) + .join(','); + // Before the fix both were hardcoded 'fr-FR' and identical. + expect(en).not.toBe(fr); + expect(en).toMatch(/Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec/); + }); }); diff --git a/packages/web/src/app/flow/Mood/lib/heatmap.ts b/packages/web/src/app/flow/Mood/lib/heatmap.ts index a2115e02..1db28e17 100644 --- a/packages/web/src/app/flow/Mood/lib/heatmap.ts +++ b/packages/web/src/app/flow/Mood/lib/heatmap.ts @@ -1,6 +1,6 @@ import type { MoodScore } from '@nodea/shared'; -import { toIsoDate } from '@/core/i18n/date-format'; +import { getMonthNames, intlLocale, toIsoDate } from '@/core/i18n/date-format'; import { rangeFor } from './date-format'; import type { HeatmapCell, MonthLabel, MoodEntry } from './types'; @@ -48,6 +48,10 @@ export function buildHeatmap( entries: ReadonlyArray, today: Date = new Date(), weeks: number = HEATMAP_WEEKS, + // Active app language for the cell tooltip + month-axis labels. + // Defaults to 'fr' so the pure-function tests (which don't wire i18n) + // keep their previous French output. + language: string = 'fr', ): { cells: Array; monthLabels: MonthLabel[]; @@ -81,12 +85,13 @@ export function buildHeatmap( const oldestMonday = new Date(lastWeekMonday); oldestMonday.setDate(lastWeekMonday.getDate() - (weeks - 1) * 7); - const sameYearFmt = new Intl.DateTimeFormat('fr-FR', { + const locale = intlLocale(language); + const sameYearFmt = new Intl.DateTimeFormat(locale, { weekday: 'long', day: 'numeric', month: 'long', }); - const crossYearFmt = new Intl.DateTimeFormat('fr-FR', { + const crossYearFmt = new Intl.DateTimeFormat(locale, { weekday: 'long', day: 'numeric', month: 'long', @@ -124,7 +129,7 @@ export function buildHeatmap( // Month labels : every time a week's Monday lands in a // different calendar month than the previous week's Monday, // drop a label. - const monthFormatter = new Intl.DateTimeFormat('fr-FR', { month: 'short' }); + const monthNames = getMonthNames(language, 'short'); const monthLabels: MonthLabel[] = []; let prevMonth = -1; for (let w = 0; w < weeks; w++) { @@ -132,7 +137,7 @@ export function buildHeatmap( const monday = new Date(lastWeekMonday); monday.setDate(lastWeekMonday.getDate() - weeksAgo * 7); if (monday.getMonth() !== prevMonth) { - monthLabels.push({ weekIndex: w, label: monthFormatter.format(monday) }); + monthLabels.push({ weekIndex: w, label: monthNames[monday.getMonth()]! }); prevMonth = monday.getMonth(); } } diff --git a/packages/web/src/app/flow/Mood/views/Chart.tsx b/packages/web/src/app/flow/Mood/views/Chart.tsx index 2fb0e510..e62f5c71 100644 --- a/packages/web/src/app/flow/Mood/views/Chart.tsx +++ b/packages/web/src/app/flow/Mood/views/Chart.tsx @@ -60,16 +60,16 @@ function toHeatmapCells( * and surfacing the score legend below the grid. */ export default function Chart() { - const { t } = useI18n(); + const { t, language } = useI18n(); const { entries, today } = useMoodData(); const { year, dayFilter, setDayFilter } = useMoodFilters(); const fullYear = useMemo( - () => buildHeatmap(year, entries, today), - [year, entries, today], + () => buildHeatmap(year, entries, today, HEATMAP_WEEKS, language), + [year, entries, today, language], ); const compact = useMemo( - () => buildHeatmap(year, entries, today, COMPACT_HEATMAP_WEEKS), - [year, entries, today], + () => buildHeatmap(year, entries, today, COMPACT_HEATMAP_WEEKS, language), + [year, entries, today, language], ); const dayLabels = [ From 28108688619812fa2d513fcccd5cfef23558e822 Mon Sep 17 00:00:00 2001 From: aliceout Date: Thu, 2 Jul 2026 16:59:54 +0300 Subject: [PATCH 05/13] refactor(journal): drop isoDay in favour of shared toIsoDate isoDay was a byte-identical re-implementation of toIsoDate (the helper core/i18n/date-format already centralises), used across Journal stats, day-density, the Journal heatmap and the Homepage Journal heatmap. Repoint all four call sites at toIsoDate and delete isoDay; toIsoDate's own tests in date-format.test.ts already cover the behaviour, so the duplicate isoDay test goes too. Co-Authored-By: Claude Opus 4.8 --- .../Homepage/components/JournalHeatmap.tsx | 5 ++--- .../src/app/flow/Journal/lib/day-density.ts | 6 ++++-- .../web/src/app/flow/Journal/lib/heatmap.ts | 5 ++--- .../web/src/app/flow/Journal/lib/stats.test.ts | 9 +-------- packages/web/src/app/flow/Journal/lib/stats.ts | 18 +++++------------- 5 files changed, 14 insertions(+), 29 deletions(-) diff --git a/packages/web/src/app/flow/Homepage/components/JournalHeatmap.tsx b/packages/web/src/app/flow/Homepage/components/JournalHeatmap.tsx index d36b5eda..3e61d394 100644 --- a/packages/web/src/app/flow/Homepage/components/JournalHeatmap.tsx +++ b/packages/web/src/app/flow/Homepage/components/JournalHeatmap.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import { formatLongDate, getMonthNames } from '@/core/i18n/date-format'; +import { formatLongDate, getMonthNames, toIsoDate } from '@/core/i18n/date-format'; import { useI18n } from '@/i18n/I18nProvider.jsx'; import { useMediaQuery } from '@/lib/use-media-query'; import Heatmap, { @@ -13,7 +13,6 @@ import { densityToIntensity, type DayDensity, } from '@/app/flow/Journal/lib/day-density'; -import { isoDay } from '@/app/flow/Journal/lib/stats'; import { useHomepageData } from '../context'; import HomeCard from './HomeCard'; @@ -67,7 +66,7 @@ export default function JournalHeatmap() { cellsOut.push(null); continue; } - const iso = isoDay(cellDate); + const iso = toIsoDate(cellDate); const density: DayDensity | undefined = byDay.get(iso); if (!density) { cellsOut.push(null); diff --git a/packages/web/src/app/flow/Journal/lib/day-density.ts b/packages/web/src/app/flow/Journal/lib/day-density.ts index 00d0c089..b66a543a 100644 --- a/packages/web/src/app/flow/Journal/lib/day-density.ts +++ b/packages/web/src/app/flow/Journal/lib/day-density.ts @@ -1,4 +1,6 @@ -import { countWords, isoDay } from './stats'; +import { toIsoDate } from '@/core/i18n/date-format'; + +import { countWords } from './stats'; /** * Per-day writing density for the Journal heatmap (issue #56). @@ -88,5 +90,5 @@ export function buildIntensityLookup( entries: ReadonlyArray, ): (date: Date) => number { const byDay = aggregateByDay(entries); - return (date) => densityToIntensity(byDay.get(isoDay(date))); + return (date) => densityToIntensity(byDay.get(toIsoDate(date))); } diff --git a/packages/web/src/app/flow/Journal/lib/heatmap.ts b/packages/web/src/app/flow/Journal/lib/heatmap.ts index e453b848..c1bde4b4 100644 --- a/packages/web/src/app/flow/Journal/lib/heatmap.ts +++ b/packages/web/src/app/flow/Journal/lib/heatmap.ts @@ -2,10 +2,9 @@ import type { HeatmapCellInput, HeatmapMonthLabel, } from '@/ui/dirk/Heatmap'; -import { formatLongDate, getMonthNames } from '@/core/i18n/date-format'; +import { formatLongDate, getMonthNames, toIsoDate } from '@/core/i18n/date-format'; import { densityToIntensity, type DayDensity } from './day-density'; -import { isoDay } from './stats'; /** Default heatmap width — matches Mood and the rest of the * GitHub-style frises in the app. */ @@ -91,7 +90,7 @@ export function buildJournalHeatmap( const cellDate = new Date(oldestMonday); cellDate.setDate(oldestMonday.getDate() + i); const cellTime = cellDate.getTime(); - const iso = isoDay(cellDate); + const iso = toIsoDate(cellDate); // Out-of-range : before the picked year's Jan 1, after its // Dec 31, or after today in « En cours ». diff --git a/packages/web/src/app/flow/Journal/lib/stats.test.ts b/packages/web/src/app/flow/Journal/lib/stats.test.ts index 9a23d335..8478cf98 100644 --- a/packages/web/src/app/flow/Journal/lib/stats.test.ts +++ b/packages/web/src/app/flow/Journal/lib/stats.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { computeStats, countWords, isoDay } from './stats'; +import { computeStats, countWords } from './stats'; import type { JournalEntry } from './types'; function fixture(partial: Partial & { id: string }): JournalEntry { @@ -34,13 +34,6 @@ describe('countWords', () => { }); }); -describe('isoDay', () => { - it('formats a Date as YYYY-MM-DD with zero-padding', () => { - expect(isoDay(new Date(2026, 0, 5))).toBe('2026-01-05'); - expect(isoDay(new Date(2024, 11, 31))).toBe('2024-12-31'); - }); -}); - describe('computeStats', () => { it('returns zeros for an empty list', () => { const s = computeStats([], TODAY); diff --git a/packages/web/src/app/flow/Journal/lib/stats.ts b/packages/web/src/app/flow/Journal/lib/stats.ts index daa1f9db..d48db87c 100644 --- a/packages/web/src/app/flow/Journal/lib/stats.ts +++ b/packages/web/src/app/flow/Journal/lib/stats.ts @@ -1,3 +1,5 @@ +import { toIsoDate } from '@/core/i18n/date-format'; + import type { JournalEntry, JournalStats } from './types'; /** @@ -29,8 +31,8 @@ export function computeStats( } const refDay = new Date(today); refDay.setHours(0, 0, 0, 0); - const todayKey = isoDay(refDay); - const yesterdayKey = isoDay(new Date(refDay.getTime() - 24 * 3600 * 1000)); + const todayKey = toIsoDate(refDay); + const yesterdayKey = toIsoDate(new Date(refDay.getTime() - 24 * 3600 * 1000)); const streakIncludesToday = dayKeys.has(todayKey); let cursor = streakIncludesToday ? new Date(refDay) @@ -38,7 +40,7 @@ export function computeStats( ? new Date(refDay.getTime() - 24 * 3600 * 1000) : null; let streakDays = 0; - while (cursor && dayKeys.has(isoDay(cursor))) { + while (cursor && dayKeys.has(toIsoDate(cursor))) { streakDays += 1; cursor = new Date(cursor.getTime() - 24 * 3600 * 1000); } @@ -57,13 +59,3 @@ export function countWords(text: string): number { if (!trimmed) return 0; return trimmed.split(/\s+/).length; } - -/** ISO `YYYY-MM-DD` for a `Date` (zero-padded month / day, local - * time). Used internally by `computeStats` for the streak Set - * lookups ; exported for the tests. */ -export function isoDay(d: Date): string { - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, '0'); - const day = String(d.getDate()).padStart(2, '0'); - return `${y}-${m}-${day}`; -} From 89b830fbab6b861a40e232d300c2fcc400ac271c Mon Sep 17 00:00:00 2001 From: aliceout Date: Thu, 2 Jul 2026 17:03:43 +0300 Subject: [PATCH 06/13] fix(mood): correct streak-range dates and keep migrated scores on the home frise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two display inconsistencies in the Mood data path: - formatStreakRange parsed the streak's ISO bounds with new Date(iso) (UTC midnight) then read local getters, shifting the « du X au Y » label back a day west of UTC. Parse with parseLocalDate like the rest of the file. - The Homepage Mood projection dropped any score outside {-2..+2}, whereas the Mood page converts legacy 0..10 scores via normalizeScore. Migrated entries thus showed on the Mood page but vanished from the home frise + average. Convert instead of drop, matching the Mood page. Update the projection test to assert conversion (was: dropped). Co-Authored-By: Claude Opus 4.8 --- .../src/app/flow/Homepage/lib/projections.test.ts | 15 +++++++++++---- .../web/src/app/flow/Homepage/lib/projections.ts | 15 ++++++++------- packages/web/src/app/flow/Mood/lib/stats.ts | 7 +++++-- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/packages/web/src/app/flow/Homepage/lib/projections.test.ts b/packages/web/src/app/flow/Homepage/lib/projections.test.ts index 1f4a1678..b2a3cd54 100644 --- a/packages/web/src/app/flow/Homepage/lib/projections.test.ts +++ b/packages/web/src/app/flow/Homepage/lib/projections.test.ts @@ -72,12 +72,19 @@ describe('projectMoodEntries', () => { expect(out).toEqual([]); }); - it('drops records with an unexpected mood score', () => { + it('converts legacy / out-of-range scores onto −2..+2 instead of dropping them', () => { + // Same normalizeScore the Mood page uses, so migrated 0..10 entries + // stay consistent between the home frise/average and the Mood page. const out = projectMoodEntries([ - moodRecord('a', { date: '2026-03-15', moodScore: '99' }), - moodRecord('b', { date: '2026-03-14', moodScore: 'neutre' }), + moodRecord('a', { date: '2026-03-15', moodScore: '7' }), // legacy 0..10 → 1 + moodRecord('b', { date: '2026-03-14', moodScore: '99' }), // out of range → clamps to 2 + moodRecord('c', { date: '2026-03-13', moodScore: 'neutre' }), // non-finite → 0 + ]); + expect(out).toEqual([ + { dateIso: '2026-03-15', score: '1', createdAt: '2026-03-15' }, + { dateIso: '2026-03-14', score: '2', createdAt: '2026-03-14' }, + { dateIso: '2026-03-13', score: '0', createdAt: '2026-03-13' }, ]); - expect(out).toEqual([]); }); }); diff --git a/packages/web/src/app/flow/Homepage/lib/projections.ts b/packages/web/src/app/flow/Homepage/lib/projections.ts index 2d49fe6a..5c656aa4 100644 --- a/packages/web/src/app/flow/Homepage/lib/projections.ts +++ b/packages/web/src/app/flow/Homepage/lib/projections.ts @@ -2,13 +2,12 @@ import type { GoalsPayload, JournalPayload, MoodPayload, - MoodScore, } from '@nodea/shared'; import type { DecryptedRecord } from '@/core/api/modules/collection-client'; import { VALID_STATUS as GOAL_VALID_STATUS } from '@/app/flow/Goals/lib/mappers'; -import { VALID_SCORES as MOOD_VALID_SCORES } from '@/app/flow/Mood/lib/mappers'; +import { normalizeScore } from '@/app/flow/Mood/lib/mappers'; import type { GoalEntryLite, @@ -23,9 +22,12 @@ import type { * Server-side timestamps are gone (minimum-readable-surface * design) — the user-facing `payload.date` is the only date we * have. Records with a missing or malformed date are dropped - * (rather than guessed at), as are records with an unexpected - * mood score. The lite `createdAt` falls back to `dateIso` so - * downstream tie-breaking still has something to compare on. + * (rather than guessed at). Legacy 0..10 mood scores are mapped + * onto −2..+2 via the Mood page's `normalizeScore` — NOT dropped — + * so migrated entries stay consistent between the home frise / + * average and the Mood page. The lite `createdAt` falls back to + * `dateIso` so downstream tie-breaking still has something to + * compare on. * * Pure : no I/O, no global clock, no React. Suitable for Vitest. */ @@ -36,11 +38,10 @@ export function projectMoodEntries( for (const r of records) { const p = r.payload; if (!p.date || !/^\d{4}-\d{2}-\d{2}/.test(p.date)) continue; - if (!MOOD_VALID_SCORES.has(p.moodScore)) continue; const dateIso = p.date.slice(0, 10); out.push({ dateIso, - score: p.moodScore as MoodScore, + score: normalizeScore(p.moodScore ?? '0'), createdAt: dateIso, }); } diff --git a/packages/web/src/app/flow/Mood/lib/stats.ts b/packages/web/src/app/flow/Mood/lib/stats.ts index c0cfc63c..5c9a0285 100644 --- a/packages/web/src/app/flow/Mood/lib/stats.ts +++ b/packages/web/src/app/flow/Mood/lib/stats.ts @@ -210,8 +210,11 @@ function formatStreakRange( monthNames: ReadonlyArray, t: StatsTranslate, ): string { - const start = new Date(startIso); - const end = new Date(endIso); + // parseLocalDate (not `new Date(iso)`) so a bare YYYY-MM-DD is read at + // LOCAL midnight — `new Date('2026-03-12')` is UTC midnight, which the + // local getters below then render as the previous day west of UTC. + const start = parseLocalDate(startIso); + const end = parseLocalDate(endIso); if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { return `${startIso} → ${endIso}`; } From 90c380ce95ee1f0290b11fc2b57fa822cdb23f5e Mon Sep 17 00:00:00 2001 From: aliceout Date: Thu, 2 Jul 2026 17:09:43 +0300 Subject: [PATCH 07/13] fix(auth): only spend the email-change quota on a successful change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 1/24h change-email limiter incremented before the handler ran, so a rejected attempt — a mistyped/already-taken address (409), a stale re-auth (401), or a cooldown hit (429) — burned the day's budget and locked an honest user out of the corrected retry for 24h. Add an opt-in skipFailedRequests to the rate-limit middleware that refunds the increment when the response is an error (status >= 400), and enable it on the change-email limiter. Add a test: a 409 followed by a corrected retry now succeeds instead of hitting the limiter. Co-Authored-By: Claude Opus 4.8 --- packages/api/src/middleware/rate-limit.ts | 22 +++++++++++++++++++ packages/api/src/routes/auth-account.ts | 8 +++++++ packages/api/src/test/auth.test.ts | 26 +++++++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/packages/api/src/middleware/rate-limit.ts b/packages/api/src/middleware/rate-limit.ts index 7eb405bb..6d65695d 100644 --- a/packages/api/src/middleware/rate-limit.ts +++ b/packages/api/src/middleware/rate-limit.ts @@ -43,6 +43,16 @@ export interface RateLimitOptions { * `null` the limiter falls back to the trusted client IP. */ keyFn?: (c: Context) => string | null; + /** + * When `true`, a request that ends in an error response (status + * ≥ 400) does NOT consume the caller's budget — the increment made + * for it is rolled back after the handler runs. Use it where the + * budget should meter *successful* actions, so a rejected attempt + * (bad input, already-taken target, a downstream 4xx) can't block a + * legitimate retry. Off by default: a login limiter, say, wants + * failures to count. + */ + skipFailedRequests?: boolean; } // Stashed on globalThis so Vitest 4's per-test-file module @@ -111,7 +121,19 @@ export function rateLimit(opts: RateLimitOptions): MiddlewareHandler { return c.json({ error: 'rate_limited' }, 429); } } + await next(); + + // `skipFailedRequests`: an error response didn't perform the metered + // action, so refund the increment we made above (only successes count). + // The limiter's own 429 returns before `next()`, so it's never refunded. + if (opts.skipFailedRequests && c.res.status >= 400) { + const b = buckets.get(key); + if (b) { + b.count -= 1; + if (b.count <= 0) buckets.delete(key); + } + } }; } diff --git a/packages/api/src/routes/auth-account.ts b/packages/api/src/routes/auth-account.ts index 964a1401..cd56736c 100644 --- a/packages/api/src/routes/auth-account.ts +++ b/packages/api/src/routes/auth-account.ts @@ -53,11 +53,19 @@ export const authAccountRoutes = makeAuthedRouter(); * for every user behind the same NAT. Now keyed on the * authenticated user id and mounted last in the chain, so only a * fully re-authenticated call consumes the quota. + * + * `skipFailedRequests` so ONLY a successful change spends the budget : + * a mistyped / already-taken address (409), a stale re-auth (401) or a + * cooldown rejection (429) rolls the increment back, so an honest user + * who fat-fingers the target isn't locked out of the corrected retry + * for 24 h. (A no-op change to one's own current address still returns + * 200 and counts — it's a completed request, not a failure.) */ const changeEmailLimiter = rateLimit({ max: 1, windowMs: 24 * 60 * 60 * 1000, keyPrefix: 'rl:change-email', + skipFailedRequests: true, keyFn: (c) => { const user = c.get('user') as { id?: string } | undefined; return user?.id ?? null; diff --git a/packages/api/src/test/auth.test.ts b/packages/api/src/test/auth.test.ts index 072c5b42..eb2cd6c3 100644 --- a/packages/api/src/test/auth.test.ts +++ b/packages/api/src/test/auth.test.ts @@ -545,6 +545,32 @@ describe('PATCH /auth/email', () => { }); expect(res.status).toBe(409); }); + + it('a failed change (409) does not burn the 24h quota — a corrected retry still succeeds', async () => { + await seedUser('taken-target@example.com'); + await seedUser('retry@example.com'); + const cookie = await loginAs(app, 'retry@example.com', TEST_PASSWORD); + + // First attempt targets an already-taken address → 409. + const proof1 = await passwordProofFor(app, 'retry@example.com', TEST_PASSWORD); + const clash = await app.request('/auth/email', { + method: 'PATCH', + headers: { 'content-type': 'application/json', cookie }, + body: JSON.stringify({ ...proof1, newEmail: 'taken-target@example.com' }), + }); + expect(clash.status).toBe(409); + + // Corrected retry to a free address, same day: the failed attempt + // must not have consumed the 1/24h budget (skipFailedRequests), so + // this succeeds instead of hitting the limiter's 429. + const proof2 = await passwordProofFor(app, 'retry@example.com', TEST_PASSWORD); + const fixed = await app.request('/auth/email', { + method: 'PATCH', + headers: { 'content-type': 'application/json', cookie }, + body: JSON.stringify({ ...proof2, newEmail: 'retry-fixed@example.com' }), + }); + expect(fixed.status).toBe(200); + }); }); describe('PATCH /auth/username', () => { From d0161870a270ae2718ea5acaa743c1f306313dfd Mon Sep 17 00:00:00 2001 From: aliceout Date: Thu, 2 Jul 2026 17:12:17 +0300 Subject: [PATCH 08/13] refactor(crypto): route quiz RNG through shared randomBytes; fix exportKey doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two crypto-convention fixes from the audit: - pickQuizPositions called crypto.getRandomValues directly (rule 3: one randomBytes source) and carried a modulo bias. Draw from the shared randomBytes helper and rejection-sample to remove the bias. Impact is cosmetic (the quizzed slots aren't secret), it's a convention fix. - The OPAQUE exportKey JSDoc claimed « 32-byte (hex) »; it's actually a ~64-byte base64url string (factor-wrap decodes it via base64UrlToBytes). Correct the comment so nobody wires a hex decode against it. Co-Authored-By: Claude Opus 4.8 --- packages/web/src/core/auth/opaque.ts | 6 ++++-- .../web/src/ui/atoms/auth/mnemonic-quiz.ts | 20 +++++++++++++------ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/web/src/core/auth/opaque.ts b/packages/web/src/core/auth/opaque.ts index e3eaf02d..8316713e 100644 --- a/packages/web/src/core/auth/opaque.ts +++ b/packages/web/src/core/auth/opaque.ts @@ -48,8 +48,10 @@ export interface ClientRegisterFinishResult { /** Wire payload for `POST /auth/register/opaque/finish` — gets stored * in `opaque_records.envelope` server-side. */ registrationRecord: string; - /** 32-byte symmetric key (hex) the client uses to derive `wk_password` - * via HKDF label `nodea:wrap-kek`. NEVER ship this to the server. */ + /** 64-byte symmetric key (base64url) the client uses to derive + * `wk_password` via HKDF label `nodea:wrap-kek`. NEVER ship this to + * the server. (The `@serenity-kit/opaque` exportKey is base64url, not + * hex — see factor-wrap.ts, which decodes it via base64UrlToBytes.) */ exportKey: string; /** Server's static public key — caller may pin it to detect a server * swap. We don't (yet); pin lookups can land in Phase 2C if useful. */ diff --git a/packages/web/src/ui/atoms/auth/mnemonic-quiz.ts b/packages/web/src/ui/atoms/auth/mnemonic-quiz.ts index f69484e3..48563d13 100644 --- a/packages/web/src/ui/atoms/auth/mnemonic-quiz.ts +++ b/packages/web/src/ui/atoms/auth/mnemonic-quiz.ts @@ -12,19 +12,27 @@ * answer-matching logic stays unit-testable without rendering. */ +import { randomBytes } from '@/core/crypto/base64'; + /** * Pick `count` DISTINCT positions in `[0, total)`, sorted ascending. - * Uses the crypto RNG so the quizzed slots aren't predictable (not a - * secret — just no fixed pattern to rote-learn). Caps at `total` when - * asked for more than exist. + * Draws from the shared CSPRNG so the quizzed slots aren't predictable + * (not a secret — just no fixed pattern to rote-learn). Caps at `total` + * when asked for more than exist. */ export function pickQuizPositions(total: number, count: number): number[] { const n = Math.min(count, Math.max(0, total)); + if (n === 0) return []; const chosen = new Set(); - const buf = new Uint32Array(1); + // Uniform index in [0, total) from the single randomBytes source + // (crypto rule 3). Rejection-sampling the final partial 2^32 block + // drops the modulo bias a bare `u32 % total` would carry. + const limit = 0x1_0000_0000 - (0x1_0000_0000 % total); while (chosen.size < n) { - crypto.getRandomValues(buf); - chosen.add(buf[0]! % total); + const bytes = randomBytes(4); + const r = new DataView(bytes.buffer, bytes.byteOffset, 4).getUint32(0); + if (r >= limit) continue; + chosen.add(r % total); } return [...chosen].sort((a, b) => a - b); } From 621dab03a422fd401db59566feb3e156079eec83 Mon Sep 17 00:00:00 2001 From: aliceout Date: Thu, 2 Jul 2026 17:20:23 +0300 Subject: [PATCH 09/13] fix(a11y): associate HRT field errors with their control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FieldRow rendered the inline error as a bare role=alert

with no id, and the controls carried no aria-describedby/aria-invalid — so a screen reader announced the error once on appearance but never re-associated it when the user returned to the invalid field. Give the error

a stable id (fieldErrorId, mirroring the Field atom), and wire every HRT form control's aria-describedby + aria-invalid at it. Add an ariaDescribedBy prop to the DateField atom so it can point there too. Co-Authored-By: Claude Opus 4.8 --- .../app/flow/HRT/components/AdminLogForm.tsx | 19 ++++++++++-- .../src/app/flow/HRT/components/FieldRow.tsx | 10 ++++++- .../app/flow/HRT/components/LabResultForm.tsx | 29 ++++++++++++++++--- .../app/flow/HRT/components/field-error-id.ts | 9 ++++++ packages/web/src/ui/atoms/dirk/DateField.tsx | 5 ++++ 5 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 packages/web/src/app/flow/HRT/components/field-error-id.ts diff --git a/packages/web/src/app/flow/HRT/components/AdminLogForm.tsx b/packages/web/src/app/flow/HRT/components/AdminLogForm.tsx index 48e79eea..7d44b2cc 100644 --- a/packages/web/src/app/flow/HRT/components/AdminLogForm.tsx +++ b/packages/web/src/app/flow/HRT/components/AdminLogForm.tsx @@ -37,6 +37,7 @@ import { categoryLabel, todayIso } from '../lib/labels'; import { doseUnitOf, mgEquivalent } from '../lib/export-model'; import type { AdminLogEntry } from '../hooks/use-admin-logs'; import FieldRow from './FieldRow'; +import { fieldErrorId } from './field-error-id'; import ProductForm from './ProductForm'; type FormIn = z.input; @@ -140,7 +141,12 @@ export default function AdminLogForm({

-
-