Audit fixes + configurable Mood composer#163
Merged
Merged
Conversation
The global reset in ui/theme/utilities.css strips the native focus outline on every <button>; only the Button atom re-added a ring, so every raw button (main nav, tab strips, filter chips, mood score picker, library search/cover/favorite controls) left keyboard focus invisible (WCAG 2.4.7). Factor the ring into a shared FOCUS_RING constant (lib/utils), have the Button atom consume it, and apply it to the ten bespoke buttons the audit flagged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…meration oracle /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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… home frise
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…rtKey doc 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 <noreply@anthropic.com>
FieldRow rendered the inline error as a bare role=alert <p> 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 <p> 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 <noreply@anthropic.com>
Exported symbols with no caller anywhere in the repo (verified, incl. tests), so no behaviour change: - api: pruneExpiredSessions (never called; the cron does the real purge, and it returned 0 anyway for lack of .returning()), BACKUP_CODE_BIT_LENGTH. - web crypto: hmacVerify (guards are verified by re-derivation), decodeBase64Url (thin wrapper over base64UrlToBytes). - web: getModuleById, Review's isOptionalSection (its Set stays — used by visibleSteps), Goals' GOAL_STATUS_LABEL (labels come from i18n now), and the orphan SurfaceCard component (+ its Architecture.md mention). Drop the now-unused lt / base64UrlToBytes imports left behind. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rework the Mood composer around the daily essentials and make the rest opt-in: - The score and a renamed free-text « Mot du jour » (payload comment) always sit in the main questionnaire. - The three positives and the « question du jour » each get an independent placement — in the questionnaire, in an expandable drawer, or not at all — via new encrypted prefs moodPositivesPlacement / moodQuestionPlacement (default: drawer; question falls back to the legacy moodOfferDailyQuestion boolean). Resolved in lib/placements.ts. - The entries list gains moodEntryLead: pick which block (positives / mot du jour / question) leads each row; the others follow in canonical order. Comment/question text now aligns with the positives' text. - Question label is inline + italic; the free field is split into its own CommentSection, OptionalsSection renamed to QuestionSection. Settings panel exposes the two placements + the lead order. Schema, resolvers and docs updated together; placements covered by a unit test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Résumé
Deux choses sur cette branche, chacune en commits séparés :
Le blob de préférences est chiffré et forward-compatible (tous les champs optionnels) → pas de migration, les anciens blobs se lisent tels quels via des fallbacks.
Correctifs d'audit
Sécurité
fix(auth)Oracle d'énumération de comptes fermé sur/auth/recover-kek/start: les blobs de récupération factices étaient tirés au hasard (donc différents à chaque appel) alors que les vrais sont stables en base — deux appels suffisaient à distinguer un compte existant. Désormais dérivés en HMAC déterministe par email (même bouclier quederiveFakeUserId). (+ test)fix(auth)Quota de changement d'email consommé seulement en cas de succès : un email déjà pris (409) ou une re-auth périmée (401) brûlait le budget 1/24h et bloquait une correction légitime. Ajout d'unskipFailedRequestsopt-in au middleware de rate-limit. (+ test)Bugs
fix(account)Plus de faux « Échec du déchiffrement » sur l'appareil courant après connexion (le sentinel'set'était re-déchiffré par l'effet voisin).fix(mood)Libellé de série daté en heure locale (fini le décalage d'un jour à l'ouest d'UTC) + les scores Mood migrés (ancienne échelle 0–10) sont convertis au lieu d'être supprimés sur l'accueil, cohérent avec la page Mood. (+ test mis à jour)fix(i18n)Noms de mois des frises (Mood/Journal/accueil) localisés — un·e utilisateur·ice en anglais ne voit plus « janv./févr. ». (+ test)Accessibilité
fix(a11y)Anneau de focus clavier restauré sur les boutons « bruts » (nav principale, onglets, chips de filtre, sélecteur de note, contrôles Library) via une constanteFOCUS_RINGpartagée. Le reset CSS global le supprimait et seul l'atomeButtonle recompensait (WCAG 2.4.7).fix(a11y)Erreurs des formulaires HRT reliées à leur contrôle (aria-describedby+aria-invalid), au lieu d'un simplerole="alert"non associé.Refactors / conventions
refactor(journal)isoDaysupprimé au profit dutoIsoDatepartagé (4 sites).refactor(crypto)Tirage du quiz mnémonique routé par lerandomBytespartagé + rejection sampling (biais modulo éliminé) ; commentaire faux sur l'exportKeyOPAQUE corrigé (base64url, pas hex).Code mort
chore8 exports sans aucun appelant supprimés (vérifiés, tests inclus) :pruneExpiredSessions,hmacVerify,decodeBase64Url,getModuleById,isOptionalSection,GOAL_STATUS_LABEL,BACKUP_CODE_BIT_LENGTH, et le composant orphelinSurfaceCard(+ sa mention dansArchitecture.md).Feature — composer Mood configurable
comment) restent toujours dans le questionnaire principal.moodPositivesPlacement/moodQuestionPlacement(défaut : dépliable ; la question retombe sur le legacymoodOfferDailyQuestion). Résolveurs purs danslib/placements.ts.moodEntryLead: choisir le bloc affiché en premier (positives / mot du jour / question) ; les autres suivent dans l'ordre canonique. Alignement des textes corrigé.CommentSection,OptionalsSection→QuestionSection.Tests
Checklist
any; requêtes paramétrées via Drizzleguardniencrypted_keyshared/🤖 Generated with Claude Code