Skip to content

Audit fixes + configurable Mood composer#163

Merged
aliceout merged 13 commits into
mainfrom
wip/bidouilles
Jul 2, 2026
Merged

Audit fixes + configurable Mood composer#163
aliceout merged 13 commits into
mainfrom
wip/bidouilles

Conversation

@aliceout

@aliceout aliceout commented Jul 2, 2026

Copy link
Copy Markdown
Owner

Résumé

Deux choses sur cette branche, chacune en commits séparés :

  1. Nettoyage piloté par un audit du code (10 commits) — correction des défauts remontés par un audit app-side : accessibilité clavier, une fuite d'énumération de comptes, quelques bugs d'affichage, des dédup de helpers et du code mort. Aucune nouvelle fonctionnalité, aucune migration DB.
  2. Refonte du composer Mood (1 commit) — placement configurable des blocs du questionnaire + ordre d'affichage des entrées.

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 que deriveFakeUserId). (+ 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'un skipFailedRequests opt-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 constante FOCUS_RING partagée. Le reset CSS global le supprimait et seul l'atome Button le recompensait (WCAG 2.4.7).
  • fix(a11y) Erreurs des formulaires HRT reliées à leur contrôle (aria-describedby + aria-invalid), au lieu d'un simple role="alert" non associé.

Refactors / conventions

  • refactor(journal) isoDay supprimé au profit du toIsoDate partagé (4 sites).
  • refactor(crypto) Tirage du quiz mnémonique routé par le randomBytes partagé + rejection sampling (biais modulo éliminé) ; commentaire faux sur l'exportKey OPAQUE corrigé (base64url, pas hex).

Code mort

  • chore 8 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 orphelin SurfaceCard (+ sa mention dans Architecture.md).

Feature — composer Mood configurable

  • La note et un champ libre renommé « Mot du jour » (payload comment) restent toujours dans le questionnaire principal.
  • Les trois positives et la question du jour ont chacune un placement indépendant — dans le questionnaire / dans le dépliable / pas du tout — via les prefs chiffrées moodPositivesPlacement / moodQuestionPlacement (défaut : dépliable ; la question retombe sur le legacy moodOfferDailyQuestion). Résolveurs purs dans lib/placements.ts.
  • La liste des entrées gagne moodEntryLead : choisir le bloc affiché en premier (positives / mot du jour / question) ; les autres suivent dans l'ordre canonique. Alignement des textes corrigé.
  • Question en italique inline ; champ libre extrait dans CommentSection, OptionalsSectionQuestionSection.
  • Panneau de réglages : les deux placements + l'ordre d'affichage.

Tests

  • Web : 616 tests verts (75 fichiers) · API : 412 tests verts (32 fichiers).
  • Typecheck + lint OK sur les 3 packages.
  • 6 nouveaux tests : déterminisme des blobs de récupération, non-consommation du quota email sur échec, localisation des mois, conversion des scores migrés, placements Mood.

Checklist

  • Pas de any ; requêtes paramétrées via Drizzle
  • Mutations via le guard middleware (inchangé ici)
  • Aucune clé/secret exposé ; ajout crypto respecte HKDF + types brandés
  • Réponses ne fuient ni guard ni encrypted_key
  • Rate-limit couvert (option opt-in, défaut inchangé)
  • Schémas Zod dans shared/
  • Docs mises à jour dans le même commit que le code (Mood.md, Architecture.md)
  • Pas de migration DB (blob de préférences forward-compatible)

🤖 Generated with Claude Code

aliceout and others added 13 commits July 2, 2026 16:45
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>
@aliceout aliceout merged commit eb94b23 into main Jul 2, 2026
3 checks passed
@aliceout aliceout deleted the wip/bidouilles branch July 2, 2026 19:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant