Compare commits

..

32 commits

Author SHA1 Message Date
le king fu
085b8e7d75 state: sync after #133, #134, #135 (PR #138)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:13:13 -04:00
le king fu
8b27054be9 fix(security): raise shell-quote, add compression and source-map-js floors
- react-devtools-core > shell-quote ^1.8.4 -> ^1.11.0 (GHSA-pqg4-j6r4-53mv,
  critical, quote() command injection; resolves 1.12.0). Raises the #95 floor.
- @expo/cli > compression ^1.8.2 (GHSA-vc2v-76pw-4v95, high, dev server DoS).
- source-map-js@^1.0.0 -> ^1.2.2 (GHSA-68fv-2mgg-jv7q, high, postcss pipeline).

Lock: only the three package entries change, package key set identical.
Smoke test: floor guard plus a call to the consumed API for each package.
Export bundle byte-identical before/after (44 files). npm audit 43 -> 40,
critical 0. Build and dev tooling only, nothing ships in the APK.

Fixes #133
Fixes #134
Fixes #135

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:07:48 -04:00
le king fu
aa41642a87 state: sync after #128, #129 (PR #131), #126 and #130
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:35:17 -04:00
le king fu
cb53a43422 fix(security): scope http-cache-semantics floor to major 4
Review follow-up on #131: a blanket override would force any future 5.x
consumer down to 4.x. The version-scoped key keeps the floor within the
current major, like postcss-selector-parser. No lockfile change.

Refs #128

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:34:25 -04:00
le king fu
62efdba22e fix(security): add http-cache-semantics and postcss-selector-parser floors
- http-cache-semantics ^4.3.0 (GHSA-ch52-4w7c-c8xp, high): 4.3.0 is out of
  the <=4.2.0 range although the advisory lists no patched version. Single
  copy via @expo/ngrok > got > cacheable-request (^4.0.0), dev tunnel only.
- postcss-selector-parser@^6.0.0 -> ^6.1.3 (GHSA-w9m9-85wc-3x92, low),
  version-scoped to the tailwindcss@3 major. Resolves to 6.1.4.

Lockfile: two stanzas, package count unchanged (2084). npm audit 33 -> 31.
Android JS bundle (expo export --no-bytecode, cache cleared) is byte-identical
before and after. Smoke test guards both floors.

Resolves #128
Resolves #129

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:27:33 -04:00
le king fu
84048e976c state: sync after #124 and #125
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:14:44 -04:00
35ab0f64ae Merge pull request 'fix(security): raise brace-expansion and undici override floors' (#127) from issue-124-125-brace-expansion-undici-floors into master 2026-10-02 18:14:26 +00:00
le king fu
84d04184ae fix(security): raise brace-expansion and undici override floors
brace-expansion: nine new DoS advisories outdate the floors set in #103.
Raise the three version-scoped overrides to the patched head of each
major (1.1.21, 2.1.7, 5.0.12). undici: raise the @expo/cli scoped
override to ^6.28.1 (resolves to 6.29.0).

Add smoke checks locking the floors and the minimatch brace/glob
behaviour for every installed minimatch major, so a return to a blanket
override fails the suite.

npm audit: 12 -> 10 entries. The rest is tracked in #123 and #126.

Fixes #124
Fixes #125

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:11:34 -04:00
le king fu
99ecb73615 chore: ignore .next/ and reports/
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:52:56 -04:00
le king fu
dacd1c285e state: sync after #120 analysis (accepted risk, unreachable)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:41:29 -04:00
le king fu
f9bed16554 state: sync after #121
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:28:08 -04:00
4c2fe06f68 Merge pull request 'fix(security): raise @xmldom/xmldom and js-yaml override floors' (#122) from issue-121-xmldom-js-yaml-floors into master 2026-09-26 00:27:54 +00:00
le king fu
100078e1e2 fix(security): raise @xmldom/xmldom and js-yaml override floors
Advisories published on 2026-09-08 land above the current floors:
- @xmldom/xmldom <= 0.8.14 (GHSA-c7q8-3ch8-vqpv, GHSA-x4fp-j954-r2f4,
  GHSA-93r5-fhx6-vmg9 and 7 more), patched in 0.8.15 (lts line)
- js-yaml < 3.15.2 / < 4.3.2 (GHSA-2883-xcg3-v3hh)

Raise the three overrides. Lock diff is limited to the three entries.
All consumers are build-time tooling (@expo/plist, plist via xcode,
@expo/xcpretty, @istanbuljs/load-nyc-config); the Android bundle hash
is unchanged.

Fixes #121

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:10:07 -04:00
le king fu
967929ee04 state: sync after #117
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:04:32 -04:00
53c33e73a4 Merge pull request 'fix(security): bump @react-navigation/core to 7.22.1 via override' (#119) from issue-117-react-navigation-core-7.22.1 into master 2026-09-26 00:04:06 +00:00
le king fu
2117cc5016 test: clarify deep link round trip scope (expo-router fork not covered)
Refs #117

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:59:17 -04:00
le king fu
f7b2b51da9 fix(security): bump @react-navigation/core to 7.22.1 via override
@react-navigation/core <= 7.22.0 depends on query-string, which pulls
decode-uri-component <= 0.4.2 (GHSA-vcc3-ghjq-m6fr, DoS on malformed
percent-encoded input). 7.22.1 parses queries itself and drops the
dependency. A scoped override bumps it without touching anything else:
the lock diff is limited to @react-navigation/core and /routers.

Add smoke-test guards for the failure mode of the superseded PR #118
(blanket npm audit fix): babel-preset-expo and @expo/metro-config must
resolve from the root node_modules, only one Expo SDK major may be
installed, every metro copy must stay on 0.83.x. Replayed against the
PR #118 lock, all four checks fail. Also add a deep link round trip
through getStateFromPath/getPathFromState with query params.

Fixes #117

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:40:36 -04:00
813b9de2ea Merge pull request 'fix(security): raise browserslist floor to 4.28.7 via override' (#116) from issue-115-browserslist-4.28.7 into master 2026-09-04 20:27:38 +00:00
le king fu
dfd4004cdc state: sync after #108 and #115
Version was still recorded as 1.6.4/16 while master is at 1.6.6/18.
Records the security remediation outcome (19 vulns / 9 HIGH -> 10 / 0
HIGH), the override inventory, and the three @expo/metro shims that no
longer resolve under metro 0.83.8 -- inert today, but worth re-checking
at the next Expo SDK bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Y4GzEsmDryrwtQcmBNw9P
2026-09-04 16:25:04 -04:00
le king fu
5e7a566155 fix(security): raise browserslist floor to 4.28.7 via override
browserslist <= 4.28.6 carries two HIGH advisories published 2026-09-01:
GHSA-c83g-rgw3-j3cx (unbounded memory growth, no cache eviction, leading
to OOM) and GHSA-73wf-gq98-2v4g (uncaught crash / prototype write via an
untrusted browserslist-stats.json in normalizeStats). Both report
first_patched_version = 4.28.7.

Unlike the image-size root fixed in #114, this one has a published fix,
so a plain version floor is enough. Every requester already uses a caret
range compatible with 4.28.7 (@babel/helper-compilation-targets ^4.24.0,
@expo/metro-config ^4.25.0, core-js-compat ^4.28.1, update-browserslist-db
peer >= 4.21.0) -- the lock was simply pinned at 4.28.1. The override
records the security floor the same way as the existing esbuild,
@xmldom/xmldom, postcss, brace-expansion and @babel/core entries.

Resolves to 4.28.9. npm audit: 11 -> 10 total, 1 HIGH -> 0 HIGH. No high
or critical finding remains in the tree.

Verified: the lock stays unchanged under re-resolution against
package.json, npm ci accepts the pair (what EAS runs), npm test 11/11,
and expo export --platform android emits a byte-identical Hermes bundle
(index-3d9ab1a7942c6dfccc79d9b280b8ce80.hbc, 8.17 MB) -- the compilation
targets browserslist resolves are unchanged.

Refs #115

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Y4GzEsmDryrwtQcmBNw9P
2026-09-04 16:23:46 -04:00
eff15ca9b8 Merge pull request 'fix(security): drop image-size via metro 0.83.8 overrides' (#114) from issue-108-metro-0.83.8 into master 2026-09-04 20:16:40 +00:00
le king fu
290c4e5e2f fix(security): drop image-size via metro 0.83.8 overrides
metro@0.83.8 (published 2026-08-19) removed the image-size dependency and
vendored a reduced dimension parser (src/lib/imageSize.js) that supports
only bmp/gif/jpeg/ktx/png/psd/svg/tiff/webp. The ICNS, JXL and HEIF
parsers named by GHSA-w3rx-r6r6-pgpr and GHSA-5p2g-fcmc-qvqq are absent
from it, so the DoS is eliminated by construction rather than by an
upstream patch: image-size still publishes no fixed version (latest is
2.0.2, and both advisories report first_patched_version = None).

metro@0.83.3 was the only requester of image-size in the whole tree, so
every transitive HIGH finding collapses with it.

@expo/metro@54.2.0 pins the 14 metro packages to exact 0.83.3, so npm
update cannot move them and an override is required. All 14 are moved
together to avoid a mixed metro tree; the existing nested ws override is
preserved through the "." key.

npm audit: 19 -> 11 total, 9 HIGH -> 1 HIGH. The remaining HIGH
(browserslist) is unrelated and pre-existing on master.

Verified: image-size absent from both the lock file and node_modules,
14 metro packages resolved to 0.83.8 with no duplicates, patch-package
postinstall still applies, npm test 11/11, and expo export --platform
android produces a Hermes bundle with PNG dimension variations resolved
through the vendored parser.

Refs #108

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Y4GzEsmDryrwtQcmBNw9P
2026-09-04 15:57:51 -04:00
le king fu
b9f45be8df chore: bump version to 1.6.6 (versionCode 18) 2026-08-12 14:20:59 -04:00
96adf00495 Merge pull request 'fix(security): resolve brace-expansion + @babel/core via version-scoped overrides' (#111) from issue-103-babel-brace-overrides into master 2026-08-12 18:14:17 +00:00
le king fu
98cd423a50 fix(security): resolve brace-expansion + @babel/core via version-scoped overrides
- brace-expansion -> ^1.1.18 / ^2.1.4 / ^5.0.9 (version-scoped per major):
  DoS advisories (exponential-time expansion + unbounded OOM). Version-scoping
  bumps each major within its own major, preserving the CJS export contract
  (default export for 1.x/2.x, named for 5.x) that a blanket override would have
  broken. This is #103's original blocker, now resolved. Verified: minimatch@3
  glob resolution works (the exact call that TypeError'd under a blanket).
- @babel/core -> ^7.29.6 (resolves 7.29.7): GHSA-4x5r-pxfx-6jf8 arbitrary file
  read via sourceMappingURL (vulnerable <=7.29.0, ~87 deduped instances).

Both build/dev-time only, not bundled in the shipped APK. npm audit 14 -> 12;
the remaining 12 high are entirely the image-size upstream-locked cascade
(no published fix, tracked in #108). Smoke test 11/11, lock diff is 1:1 version
replacements (large count driven by @babel/core's deduped instances).

Fixes #103
Fixes #110

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 14:06:49 -04:00
5aec3b5271 Merge pull request 'fix(security): bump undici + js-yaml overrides to patched versions' (#109) from issue-106-107-deps-overrides into master 2026-08-12 01:41:30 +00:00
le king fu
0e4c5cdb4e fix(security): bump undici + js-yaml overrides to patched versions
- @expo/cli > undici ^6.27.0 -> ^6.28.0: GHSA-8xcm-r25x-g524 / m8rv-5g2x-5cg5
  / v3r7-h72x-cjcm (response desync, CRLF & cookie injection; vulnerable <6.28.0)
- js-yaml -> ^3.15.1 (3.x) + ^4.3.1 (4.x): CVE-2026-59870 / GHSA-5p4m-2wfm-xmqj
  (quadratic CPU in !!omap resolution; vulnerable 3.x<3.15.1, 4.x<4.3.1)

js-yaml moved from parent-scoped overrides (@expo/xcpretty,
@istanbuljs/load-nyc-config) to version-scoped ones (js-yaml@^3 / js-yaml@^4):
the 4.x instance is reached via @expo/cli, which is itself overridden, and a
nested override does not propagate through an overridden parent (npm left
js-yaml@4.3.0 "invalid"). Version-scoped targeting resolves it regardless of
the parent path.

Both build/dev-time only, not bundled in the shipped APK. npm audit drops
undici + js-yaml (16 -> 14). Remaining 13 high = image-size + its metro/RN
cascade (upstream-locked, tracked in #108) + @babel/core low. Smoke test green,
widget patch reapplied, lock diff limited to the 3 bumped packages.

Fixes #106
Fixes #107

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:47:23 -04:00
2da7c0d67d Merge pull request 'fix(security): bump postcss + @expo/cli tar overrides to patched versions' (#105) from issue-102-104-deps-overrides into master 2026-08-11 13:26:30 +00:00
le king fu
9eaa103a87 fix(security): bump postcss + @expo/cli tar overrides to patched versions
- postcss ^8.5.10 -> ^8.5.18 (resolves to 8.5.26): GHSA-r28c-9q8g-f849,
  path traversal in source-map auto-loading (vulnerable <=8.5.17)
- @expo/cli > tar ^7.5.19 -> ^7.5.21 (resolves to 7.5.22): GHSA-r292-9mhp-454m,
  uncontrolled recursion stack-overflow DoS (vulnerable <=7.5.20)
- nanoid 3.3.11 -> 3.3.18 pulled in by postcss re-resolution (bonus)

Both are build/dev-time only, not bundled in the shipped APK. npm audit no
longer lists postcss (nor its transitive inflation expo/@expo/cli/
@expo/metro-config) nor tar. Smoke test green (11/11), widget patch-package
reapplied, diff limited to package.json + package-lock.json.

Fixes #102
Fixes #104

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 08:28:47 -04:00
le king fu
d4c77e640c state: sync after #68 2026-07-27 20:56:16 -04:00
a45860e5fd Merge pull request 'feat(feedback): integrate Feedback Hub widget in Settings' (#101) from issue-68-feedback-widget into master 2026-07-28 00:54:15 +00:00
le king fu
9e0adaf5c9 feat(feedback): integrate Feedback Hub widget in Settings (#68)
Add an in-app feedback form that POSTs to the centralized Feedback Hub
(feedback.lacompagniemaximus.com/api/feedback) with app_id "simpl-liste".

- src/services/feedback.ts: dependency-free client (pure helpers + fetch),
  strict context whitelist, 2000-char cap, stable error codes
- src/hooks/useFeedback.ts: idle/sending/success/error state machine
- src/components/FeedbackModal.tsx: RN bottom-sheet with char counter,
  opt-in navigation context and opt-in identity (Loi 25: both unchecked)
- Settings > About: new "Send feedback" button above the kept mailto entry
- i18n fr/en feedback block
- tests/feedback.test.mjs: node:test execution coverage of the client;
  smoke.test.cjs static guards (app_id, endpoint, cap, import-free)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 19:43:12 -04:00
13 changed files with 1354 additions and 378 deletions

6
.gitignore vendored
View file

@ -40,3 +40,9 @@ yarn-error.*
# generated native folders
/ios
/android
# Next.js build output (web companion)
/.next/
# autopilot daily reports
/reports/

View file

@ -1,13 +1,34 @@
# STATE — simpl-liste
> Derniere MAJ : 2026-05-30 (deploy web liste.lacompagnie + #90)
> Derniere MAJ : 2026-10-06 (par fix-issue #133, #134, #135 — PR #138)
## Position actuelle
Version 1.6.4 (versionCode 16). Remediation vulnerabilites du defenseur en cours
via overrides `@xmldom/xmldom@^0.8.13` et `uuid@^11.0.0` dans `package.json`
(overnight 2026-04-24). 4 CVE HIGH xmldom nettoyees ; 1 advisory uuid residuelle
non-exploitable en pratique (details dans `SECURITY.md`).
Version 1.6.6 (versionCode 18). `npm audit` = 40 entrees (0 critical) au 2026-10-06 apres
PR #138, pour 5 advisories sans correctif publie (le reste est de la cascade de parents) :
`braces` (high, #130), `node-forge` (high, #123), `postcss-selector-parser` GHSA-rj75
(moderate, #136, sortie = tailwind 4), `sprintf-js` (moderate, #137) et la chaine
`expo-router` -> `query-string@7` -> `decode-uri-component` (risque accepte #120). Toutes
sont de l'outillage de build/dev : rien dans l'APK.
La remediation passe par des overrides de plancher dans `package.json` :
`esbuild`, `@xmldom/xmldom@^0.8.15`, `uuid`, `postcss`, `js-yaml` (3.15.2 / 4.3.2), `brace-expansion` (1.1.21 / 2.1.7 / 5.0.12),
`@babel/core`, `browserslist@^4.28.7`, `@react-navigation/core@^7.22.1`,
`http-cache-semantics@^4` (4.3.0), `postcss-selector-parser@^6` (6.1.3), `source-map-js@^1` (1.2.2),
`react-devtools-core > shell-quote` (^1.11.0), `@expo/cli > compression` (^1.8.2), et les 14
paquets `metro@^0.83.8`. Le smoke test verrouille la chaine de build (presets Expo
hoistes a la racine, un seul SDK Expo, metro 0.83.x partout) et les planchers
`brace-expansion` / `undici` / `http-cache-semantics` / `postcss-selector-parser` /
`shell-quote` / `compression` / `source-map-js` (plancher + appel de l'API consommee) /
comportement de `minimatch` (majeurs 3, 9, 10).
**Caveat sur l'override metro** (releve par la review de PR #114) : `0.83.3 -> 0.83.8`
n'est pas un bump de patch pur cote API privee. Trois shims de `@expo/metro@54.2.0`,
ecrit contre 0.83.3, ne resolvent plus — `metro-resolver/utils/toPosixPath` et
`metro/node-haste/Package` supprimes, `metro-file-map/lib/dependencyExtractor` deplace.
Ils leveraient `MODULE_NOT_FOUND`, mais **aucun paquet de l'arbre ne les requiert**
(203 des 206 imports profonds `metro*/private/...` resolvent). Inerte aujourd'hui ;
a re-verifier au prochain bump du SDK Expo ou de `@expo/metro`.
Companion web `liste.lacompagniemaximus.com` : deploye **manuellement** (docker-compose
standalone `/data/simpl-liste-web` sur le VPS, build depuis `source/` rsync — PAS une app
@ -15,15 +36,21 @@ Coolify malgre la spec). Procedure en memoire `simpl-liste-web-deploy`.
## Decisions recentes
- 2026-05-30 : #90 merged (PR #93, ThemeToggle eslint-disable) + premier deploy web depuis 7 sem (rsync source/ + docker compose --build sur VPS) — #70 et #90 live sur liste.lacompagniemaximus.com.
- 2026-05-30 : PR #89 merged — display name web resolu via userInfo (ordre fallback de la vitrine), fix #70.
- 2026-05-30 : nettoyage lint web/ (PR #91, 2 prefer-const + import inutilise) ; #90 analyse (ThemeToggle set-state-in-effect, decision : eslint-disable documente).
- 2026-05-10 : Aligne 6 patches Expo SDK 54 via expo install --fix — expo-doctor 17/17 (ref #87)
- 2026-05-08 : Defenseur rerun confirme 0 findings — overrides existants suffisent, pas de PR necessaire (ref #81)
- 2026-04-24 : overrides xmldom + uuid (spec `spec-decisions-vuln-simpl-liste.md`) — PRs #77, #78, #79 (pending-human).
- 2026-04-23 : PR #71 merged — fix widget render-optimiste + timing instrumentation.
- 2026-04-18 : archive milestone `spec-simpl-liste-web` (12/12 done).
- 2026-10-06 : #133, #134, #135 fermees (PR #138 merged) — `shell-quote` ^1.11.0 (CRITICAL GHSA-pqg4, releve le plancher de #95), `@expo/cli > compression` ^1.8.2 (HIGH), `source-map-js@^1.0.0` ^1.2.2 (HIGH, pipeline CSS). `npm audit` 43 -> 40, critical 0. Export identique octet pour octet (44 fichiers), serveur de dev sert le bundle en gzip. Ouvertes le meme jour en plancher : #136 (`postcss-selector-parser` GHSA-rj75, aucun correctif 6.x) et #137 (`sprintf-js`, tete publiee vulnerable). `/analyse-vulnerabilite` cherche desormais les antecedents fermes avant de proposer.
- 2026-10-05 : #128 et #129 fermees (PR #131 merged) — planchers version-scoped `http-cache-semantics@^4.0.0` -> ^4.3.0 (GHSA-ch52 high : l'advisory ne liste aucun patch, mais 4.3.0 sort de la borne `<=4.2.0` ; tunnel `@expo/ngrok` seulement) et `postcss-selector-parser@^6.0.0` -> ^6.1.3 (low, pipeline tailwind). `npm audit` 33 -> 31. Preuve du livrable : bundle JS (`expo export --no-bytecode --clear`) identique octet pour octet. **Le hash `.hbc` n'est pas une preuve** : Hermes n'est pas deterministe ici (deux exports du meme arbre different).
- 2026-10-05 : #126 fermee, acceptation de #120 maintenue — `decode-uri-component@0.5.0` est ESM pur, `query-string@7` le `require()`. Declencheur de sortie : `expo-router@58.0.13` (SDK 58) retire `query-string`, pas encore backporte sur sdk-54..57. #130 ouverte : `braces` plancher (3.0.3 = tete publiee, vulnerable).
- 2026-10-02 : #124 et #125 fermees (PR #127 merged) — planchers `brace-expansion` ^1.1.21 / ^2.1.7 / ^5.0.12 (9 advisories DoS neuves, toujours version-scoped) et `@expo/cli > undici` ^6.28.1 (resolu 6.29.0). Outillage de build seulement. Trois gardes au smoke test, dont le test `minimatch` qui manquait depuis #103. `npm audit` 12 -> 10. Ouvertes le meme jour : #123 (`node-forge`, tete publiee 1.4.0 vulnerable) et #126 (`decode-uri-component@0.5.0` publiee).
- 2026-09-27 : #120 fermee en risque accepte (rectification du cadrage « attend un bump de SDK »). Aucune version d'expo-router (jusqu'a 58.0.8 / sdk-57) ne quitte `query-string@7`. expo-router n'appelle que `queryString.stringify()`, qui ne touche jamais `decode-uri-component` (le seul `parse()` du fork est commente) : code vulnerable inatteignable. A re-verifier a chaque bump d'expo-router.
- 2026-09-25 : #121 fermee (PR #122 merged) — planchers `@xmldom/xmldom@^0.8.15` et `js-yaml` ^3.15.2 / ^4.3.2 (advisories HIGH du 2026-09-08, outillage de build seulement, hash du bundle Hermes inchange). Le Defenseur ne les avait pas signalees : hypothese de dedup par paquet contre issues fermees -> agent-defenseurs#139.
- 2026-09-25 : #117 fermee (PR #119 merged) — override `@react-navigation/core@^7.22.1`, qui ne depend plus de `query-string` (GHSA-vcc3-ghjq-m6fr). Lock : 2 entrees. PR #118 du Defenseur fermee : son `npm audit fix` global desinstallait `babel-preset-expo`/`@expo/metro-config` de la racine et nichait un Expo 57 (build casse, tests verts). Gardes de toolchain ajoutees au smoke test. Residu `expo-router` -> #120.
- 2026-09-04 : #115 ouverte + PR #116 — `browserslist@^4.28.7` (2 advisories HIGH publiees le 2026-09-01, `first_patched_version = 4.28.7`). Resout en 4.28.9, derniere HIGH de l'arbre. Bundle Hermes au hash identique avant/apres : cibles de compilation inchangees.
- 2026-09-04 : #112 fermee doublon de #108 ; PR #113 fermee sans merge (son diff ne contenait aucune occurrence d'`image-size` — 633/584 de churn de lockfile sans rapport).
- 2026-09-04 : #108 fermee (PR #114 merged) — `image-size` retiree de l'arbre via overrides `metro@^0.83.8`. Le blocage upstream tient toujours (`first_patched_version = None`, `2.0.2` reste la derniere publiee) ; c'est metro qui a **supprime** la dependance et vendore un parseur reduit sans les formats ICNS/JXL/HEIF vises. Backport sur la ligne 0.83.x le 2026-08-19 — pas besoin du bump de SDK Expo anticipe. 9 HIGH -> 1 HIGH.
## Blockers actifs
Aucun.
- Essai sur appareil a faire avant le prochain APK : core 7.22.1 tourne avec native/native-stack/bottom-tabs publies contre 7.14 (deep link vers une tache, onglets, pile).
- #130 : `braces` <=3.0.3 sans version corrigee. Surveiller `braces` > 3.0.3 ou `micromatch` qui la largue.
- #123 : `node-forge` <=1.4.0 sans version corrigee. Surveiller `node-forge` > 1.4.0 ou le retrait de la dependance par `@expo/code-signing-certificates` / `@expo/cli`.
- #136 : `postcss-selector-parser` GHSA-rj75 sans correctif 6.x. Surveiller un backport 6.x ou la migration NativeWind 5 / tailwind 4.
- #137 : `sprintf-js` <=1.1.3 sans version corrigee. Surveiller une version hors borne ou une `@istanbuljs/load-nyc-config` 2.x.

View file

@ -2,7 +2,7 @@
"expo": {
"name": "Simpl-Liste",
"slug": "simpl-liste",
"version": "1.6.5",
"version": "1.6.6",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "simplliste",
@ -24,7 +24,7 @@
"backgroundColor": "#FFF8F0"
},
"edgeToEdgeEnabled": true,
"versionCode": 17
"versionCode": 18
},
"plugins": [
"expo-router",

View file

@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import { View, Text, Pressable, useColorScheme, TextInput, ScrollView, Alert, Modal, Platform, Switch, Linking, ActivityIndicator } from 'react-native';
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
import { useTranslation } from 'react-i18next';
import { Sun, Moon, Smartphone, Plus, Trash2, Pencil, Bell, CalendarDays, LayoutGrid, Mail, RefreshCw, Cloud, LogIn, LogOut } from 'lucide-react-native';
import { Sun, Moon, Smartphone, Plus, Trash2, Pencil, Bell, CalendarDays, LayoutGrid, Mail, RefreshCw, Cloud, LogIn, LogOut, MessageSquarePlus } from 'lucide-react-native';
import Constants from 'expo-constants';
import { useLogto } from '@logto/rn';
@ -14,6 +14,7 @@ import { initCalendar } from '@/src/services/calendar';
import { syncWidgetData } from '@/src/services/widgetSync';
import { fullSync, initialMerge, initialReset } from '@/src/services/syncClient';
import i18n from '@/src/i18n';
import FeedbackModal from '@/src/components/FeedbackModal';
type ThemeMode = 'light' | 'dark' | 'system';
@ -40,6 +41,7 @@ export default function SettingsScreen() {
const [tagName, setTagName] = useState('');
const [tagColor, setTagColor] = useState(TAG_COLORS[0]);
const [checkingUpdate, setCheckingUpdate] = useState(false);
const [showFeedback, setShowFeedback] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const { signIn: logtoSignIn, signOut: logtoSignOut, getIdTokenClaims, isAuthenticated } = useLogto();
@ -694,6 +696,12 @@ export default function SettingsScreen() {
</KeyboardAvoidingView>
</Modal>
<FeedbackModal
visible={showFeedback}
onClose={() => setShowFeedback(false)}
isDark={isDark}
/>
{/* About Section */}
<View className="px-4 pb-8 pt-6">
<Text
@ -728,6 +736,18 @@ export default function SettingsScreen() {
{t('settings.checkUpdate')}
</Text>
</Pressable>
<Pressable
onPress={() => setShowFeedback(true)}
className={`flex-row items-center border-b px-4 py-3.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}
>
<MessageSquarePlus size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
<Text
className={`ml-3 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{t('feedback.open')}
</Text>
</Pressable>
<Pressable
onPress={() => Linking.openURL('mailto:lacompagniemaximus@protonmail.com')}
className="flex-row items-center px-4 py-3.5"

723
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,10 @@
{
"name": "simpl-liste",
"main": "index.js",
"version": "1.6.5",
"version": "1.6.6",
"scripts": {
"start": "expo start",
"test": "node tests/smoke.test.cjs",
"test": "node tests/smoke.test.cjs && node --test --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON tests/feedback.test.mjs",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
@ -68,16 +68,27 @@
},
"overrides": {
"esbuild": "^0.25.0",
"@xmldom/xmldom": "^0.8.13",
"@xmldom/xmldom": "^0.8.15",
"uuid": "^11.1.1",
"postcss": "^8.5.10",
"postcss": "^8.5.18",
"postcss-selector-parser@^6.0.0": "^6.1.3",
"source-map-js@^1.0.0": "^1.2.2",
"http-cache-semantics@^4.0.0": "^4.3.0",
"js-yaml@^3.0.0": "^3.15.2",
"js-yaml@^4.0.0": "^4.3.2",
"brace-expansion@^1.0.0": "^1.1.21",
"brace-expansion@^2.0.0": "^2.1.7",
"brace-expansion@^5.0.0": "^5.0.12",
"@babel/core": "^7.29.6",
"browserslist": "^4.28.7",
"@expo/cli": {
"compression": "^1.8.2",
"ws": "^8.20.1",
"undici": "^6.27.0",
"tar": "^7.5.19"
"undici": "^6.28.1",
"tar": "^7.5.21"
},
"react-devtools-core": {
"shell-quote": "^1.8.4",
"shell-quote": "^1.11.0",
"ws": "^7.5.11"
},
"react-native": {
@ -87,14 +98,23 @@
"ws": "^7.5.11"
},
"metro": {
"ws": "^7.5.11"
"ws": "^7.5.11",
".": "^0.83.8"
},
"@expo/xcpretty": {
"js-yaml": "^4.2.0"
},
"@istanbuljs/load-nyc-config": {
"js-yaml": "^3.15.0"
}
"metro-babel-transformer": "^0.83.8",
"metro-cache": "^0.83.8",
"metro-cache-key": "^0.83.8",
"metro-config": "^0.83.8",
"metro-core": "^0.83.8",
"metro-file-map": "^0.83.8",
"metro-minify-terser": "^0.83.8",
"metro-resolver": "^0.83.8",
"metro-runtime": "^0.83.8",
"metro-source-map": "^0.83.8",
"metro-symbolicate": "^0.83.8",
"metro-transform-plugins": "^0.83.8",
"metro-transform-worker": "^0.83.8",
"@react-navigation/core": "^7.22.1"
},
"private": true
}

View file

@ -0,0 +1,253 @@
import { useEffect, useState } from 'react';
import {
View,
Text,
Pressable,
Modal,
TextInput,
ActivityIndicator,
Dimensions,
Platform,
} from 'react-native';
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
import { usePathname } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { X, MessageSquarePlus, CheckCircle, AlertCircle, Check } from 'lucide-react-native';
import Constants from 'expo-constants';
import { colors } from '@/src/theme/colors';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import { useFeedback } from '@/src/hooks/useFeedback';
import { MAX_CONTENT_LENGTH, type FeedbackContext } from '@/src/services/feedback';
interface FeedbackModalProps {
visible: boolean;
onClose: () => void;
isDark: boolean;
}
const AUTO_CLOSE_MS = 2000;
export default function FeedbackModal({ visible, onClose, isDark }: FeedbackModalProps) {
const { t, i18n } = useTranslation();
const pathname = usePathname();
const { userId } = useSettingsStore();
const { state, submit, reset } = useFeedback();
const [content, setContent] = useState('');
const [includeContext, setIncludeContext] = useState(false);
const [identify, setIdentify] = useState(false);
const trimmed = content.trim();
const isSending = state.status === 'sending';
const isSuccess = state.status === 'success';
const canSubmit = trimmed.length > 0 && !isSending && !isSuccess;
const textColor = isDark ? '#F5F5F5' : '#1A1A1A';
const mutedColor = isDark ? '#A0A0A0' : '#6B6B6B';
const borderColor = isDark ? '#3A3A3A' : '#E5E7EB';
const successColor = colors.priority.low;
const errorColor = colors.terracotta.DEFAULT;
// Reset both local and hook state each time the modal opens.
useEffect(() => {
if (visible) {
setContent('');
setIncludeContext(false);
setIdentify(false);
reset();
}
}, [visible, reset]);
// Auto-close after a successful send; timer cleared on unmount / state change.
useEffect(() => {
if (!isSuccess) return;
const timer = setTimeout(() => {
onClose();
reset();
}, AUTO_CLOSE_MS);
return () => clearTimeout(timer);
}, [isSuccess, onClose, reset]);
const handleClose = () => {
if (isSending) return;
onClose();
};
const handleSubmit = async () => {
if (!canSubmit) return;
let context: FeedbackContext | undefined;
if (includeContext) {
const { width, height } = Dimensions.get('window');
context = {
page: pathname,
locale: i18n.language,
theme: isDark ? 'dark' : 'light',
viewport: `${Math.round(width)}x${Math.round(height)}`,
userAgent: `Simpl-Liste/${Constants.expoConfig?.version ?? '0.0.0'} (${Platform.OS})`,
timestamp: new Date().toISOString(),
};
}
const feedbackUserId = identify && userId ? userId : null;
await submit({ content, userId: feedbackUserId, context });
};
const errorMessage = (() => {
if (state.status !== 'error' || !state.errorCode) return null;
switch (state.errorCode) {
case 'rate_limit':
return t('feedback.error.rateLimit');
case 'invalid':
return t('feedback.error.invalid');
default:
return t('feedback.error.generic');
}
})();
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={handleClose}>
<KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
<Pressable onPress={handleClose} className="flex-1 justify-end bg-black/40">
<Pressable
onPress={(e) => e.stopPropagation()}
className={`rounded-t-2xl px-4 pb-8 pt-4 ${isDark ? 'bg-[#2A2A2A]' : 'bg-white'}`}
>
{/* Header */}
<View className="mb-4 flex-row items-center justify-between">
<View className="flex-row items-center">
<MessageSquarePlus size={20} color={colors.bleu.DEFAULT} />
<Text className="ml-2 text-lg" style={{ fontFamily: 'Inter_600SemiBold', color: textColor }}>
{t('feedback.title')}
</Text>
</View>
<Pressable onPress={handleClose} disabled={isSending} className="p-1">
<X size={20} color={mutedColor} />
</Pressable>
</View>
{isSuccess ? (
<View className="items-center justify-center py-10">
<CheckCircle size={40} color={successColor} />
<Text
className="mt-3 text-base"
style={{ fontFamily: 'Inter_500Medium', color: textColor }}
>
{t('feedback.success')}
</Text>
</View>
) : (
<>
<TextInput
value={content}
onChangeText={(v) => setContent(v.slice(0, MAX_CONTENT_LENGTH))}
placeholder={t('feedback.placeholder')}
placeholderTextColor={mutedColor}
editable={!isSending}
multiline
className="min-h-[120px] rounded-xl border px-3 py-2.5 text-base"
style={{
fontFamily: 'Inter_400Regular',
color: textColor,
borderColor,
textAlignVertical: 'top',
}}
/>
<Text className="mt-1 self-end text-xs" style={{ color: mutedColor }}>
{content.length}/{MAX_CONTENT_LENGTH}
</Text>
{/* Opt-in: navigation context */}
<Pressable
onPress={() => setIncludeContext((v) => !v)}
disabled={isSending}
className="mt-3 flex-row items-center"
>
<View
className="h-5 w-5 items-center justify-center rounded border"
style={{
borderColor: includeContext ? colors.bleu.DEFAULT : borderColor,
backgroundColor: includeContext ? colors.bleu.DEFAULT : 'transparent',
}}
>
{includeContext && <Check size={14} color="#FFFFFF" />}
</View>
<Text
className="ml-2 flex-1 text-sm"
style={{ fontFamily: 'Inter_400Regular', color: textColor }}
>
{t('feedback.includeContext')}
</Text>
</Pressable>
{/* Opt-in: identify (only when signed in) */}
{!!userId && (
<Pressable
onPress={() => setIdentify((v) => !v)}
disabled={isSending}
className="mt-2 flex-row items-center"
>
<View
className="h-5 w-5 items-center justify-center rounded border"
style={{
borderColor: identify ? colors.bleu.DEFAULT : borderColor,
backgroundColor: identify ? colors.bleu.DEFAULT : 'transparent',
}}
>
{identify && <Check size={14} color="#FFFFFF" />}
</View>
<Text
className="ml-2 flex-1 text-sm"
style={{ fontFamily: 'Inter_400Regular', color: textColor }}
>
{t('feedback.identify')}
</Text>
</Pressable>
)}
{errorMessage && (
<View className="mt-3 flex-row items-center">
<AlertCircle size={16} color={errorColor} />
<Text
className="ml-2 flex-1 text-sm"
style={{ fontFamily: 'Inter_400Regular', color: errorColor }}
>
{errorMessage}
</Text>
</View>
)}
{/* Actions */}
<View className="mt-5 flex-row items-center justify-end">
<Pressable onPress={handleClose} disabled={isSending} className="mr-2 px-4 py-2.5">
<Text
className="text-sm"
style={{ fontFamily: 'Inter_500Medium', color: mutedColor }}
>
{t('feedback.cancel')}
</Text>
</Pressable>
<Pressable
onPress={handleSubmit}
disabled={!canSubmit}
className="rounded-lg bg-bleu px-4 py-2.5"
style={{ opacity: canSubmit ? 1 : 0.5 }}
>
{isSending ? (
<ActivityIndicator size={18} color="#FFFFFF" />
) : (
<Text className="text-sm text-white" style={{ fontFamily: 'Inter_600SemiBold' }}>
{t('feedback.submit')}
</Text>
)}
</Pressable>
</View>
</>
)}
</Pressable>
</Pressable>
</KeyboardAvoidingView>
</Modal>
);
}

55
src/hooks/useFeedback.ts Normal file
View file

@ -0,0 +1,55 @@
import { useCallback, useReducer } from 'react';
import {
sendFeedback,
type FeedbackErrorCode,
type SendFeedbackInput,
} from '@/src/services/feedback';
export type FeedbackStatus = 'idle' | 'sending' | 'success' | 'error';
export interface FeedbackState {
status: FeedbackStatus;
errorCode: FeedbackErrorCode | null;
}
type FeedbackAction =
| { type: 'SEND_START' }
| { type: 'SEND_SUCCESS' }
| { type: 'SEND_ERROR'; code: FeedbackErrorCode }
| { type: 'RESET' };
const initialState: FeedbackState = { status: 'idle', errorCode: null };
function reducer(_state: FeedbackState, action: FeedbackAction): FeedbackState {
switch (action.type) {
case 'SEND_START':
return { status: 'sending', errorCode: null };
case 'SEND_SUCCESS':
return { status: 'success', errorCode: null };
case 'SEND_ERROR':
return { status: 'error', errorCode: action.code };
case 'RESET':
return initialState;
}
}
// Feedback submission state machine (idle -> sending -> success | error).
// The network call never throws; the outcome is derived from its result.
export function useFeedback() {
const [state, dispatch] = useReducer(reducer, initialState);
const submit = useCallback(async (args: SendFeedbackInput) => {
dispatch({ type: 'SEND_START' });
const result = await sendFeedback(args);
if (result.ok) {
dispatch({ type: 'SEND_SUCCESS' });
} else {
dispatch({ type: 'SEND_ERROR', code: result.code });
}
}, []);
const reset = useCallback(() => dispatch({ type: 'RESET' }), []);
return { state, submit, reset };
}

View file

@ -105,6 +105,21 @@
"download": "Download",
"updateError": "Unable to check for updates"
},
"feedback": {
"open": "Send feedback",
"title": "Your feedback",
"placeholder": "Describe your suggestion or the issue you ran into...",
"cancel": "Cancel",
"submit": "Send",
"includeContext": "Include navigation context",
"identify": "Identify me (attach my account)",
"success": "Thanks for your feedback!",
"error": {
"rateLimit": "Too many submissions. Try again in a moment.",
"invalid": "Invalid message. Check the content.",
"generic": "Couldn't send. Try again later."
}
},
"notifications": {
"title": "Notifications",
"enabled": "Reminders enabled",

View file

@ -105,6 +105,21 @@
"download": "Télécharger",
"updateError": "Impossible de vérifier les mises à jour"
},
"feedback": {
"open": "Envoyer un feedback",
"title": "Votre feedback",
"placeholder": "Décrivez votre suggestion ou le problème rencontré...",
"cancel": "Annuler",
"submit": "Envoyer",
"includeContext": "Inclure le contexte de navigation",
"identify": "M'identifier (joindre mon compte)",
"success": "Merci pour votre feedback !",
"error": {
"rateLimit": "Trop d'envois. Réessayez dans un moment.",
"invalid": "Message invalide. Vérifiez le contenu.",
"generic": "Envoi impossible. Réessayez plus tard."
}
},
"notifications": {
"title": "Notifications",
"enabled": "Rappels activés",

100
src/services/feedback.ts Normal file
View file

@ -0,0 +1,100 @@
// Feedback Hub client — posts user feedback to the centralized micro-service
// (feedback.lacompagniemaximus.com). The endpoint is public (no auth) and
// re-sanitizes everything server-side; the mirror logic here keeps the payload
// minimal and predictable.
//
// IMPORTANT: this module is deliberately dependency-free (no RN/Expo imports,
// no `@/` path alias, only the global `fetch`/`console`). Its pure helpers are
// unit-tested with `node --test` via native TS type-stripping
// (tests/feedback.test.mjs), which cannot resolve the `@/` alias. Keep it
// import-free so that test path stays valid — the smoke test enforces this.
export const FEEDBACK_HUB_URL = 'https://feedback.lacompagniemaximus.com';
export const APP_ID = 'simpl-liste';
export const MAX_CONTENT_LENGTH = 2000;
const MAX_CONTEXT_VALUE_LENGTH = 500;
// Strict whitelist mirrored from the server's `sanitizeContext`. Any other key
// is dropped server-side anyway; we drop it here too.
export const CONTEXT_KEYS = [
'page',
'locale',
'theme',
'viewport',
'userAgent',
'timestamp',
] as const;
export type FeedbackContextKey = (typeof CONTEXT_KEYS)[number];
export type FeedbackContext = Partial<Record<FeedbackContextKey, string>>;
export type FeedbackErrorCode =
| 'invalid'
| 'rate_limit'
| 'server_error'
| 'network_error';
export type FeedbackResult = { ok: true } | { ok: false; code: FeedbackErrorCode };
export interface SendFeedbackInput {
content: string;
userId?: string | null;
context?: FeedbackContext;
}
// Client-side cap on the raw text the user typed. Note: the server strips HTML
// *then* caps at 2000, so markup-heavy input may be shortened server-side. The
// server is authoritative; here we cap the raw string so the char counter and
// payload stay bounded.
export function capContent(content: string): string {
return content.trim().slice(0, MAX_CONTENT_LENGTH);
}
// Keep only whitelisted string keys, each truncated to 500 chars. Returns
// undefined when nothing survives so the field is omitted from the payload.
export function sanitizeContext(
raw: FeedbackContext | null | undefined,
): FeedbackContext | undefined {
if (!raw) return undefined;
const out: FeedbackContext = {};
for (const key of CONTEXT_KEYS) {
const value = raw[key];
if (typeof value === 'string' && value.length > 0) {
out[key] = value.slice(0, MAX_CONTEXT_VALUE_LENGTH);
}
}
return Object.keys(out).length > 0 ? out : undefined;
}
// Map a non-ok HTTP status to a stable error code. 400 = bad app_id/empty
// content, 429 = rate limit (5/hour/IP). Anything else (403/404/413/5xx) is a
// server_error so the UI never lands on an undefined state.
export function resolveErrorCode(status: number): FeedbackErrorCode {
if (status === 400) return 'invalid';
if (status === 429) return 'rate_limit';
return 'server_error';
}
export async function sendFeedback(input: SendFeedbackInput): Promise<FeedbackResult> {
const context = sanitizeContext(input.context);
const body: Record<string, unknown> = {
app_id: APP_ID,
content: capContent(input.content),
user_id: input.userId ?? null,
};
if (context) body.context = context;
try {
const res = await fetch(`${FEEDBACK_HUB_URL}/api/feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (res.ok) return { ok: true };
console.warn(`[feedback] request failed with status ${res.status}`);
return { ok: false, code: resolveErrorCode(res.status) };
} catch (err) {
console.warn('[feedback] network error:', err);
return { ok: false, code: 'network_error' };
}
}

149
tests/feedback.test.mjs Normal file
View file

@ -0,0 +1,149 @@
// Execution tests for the Feedback Hub client. Runs on plain node via the
// built-in test runner + native TS type-stripping (Node >= 22.18):
//
// node --test tests/feedback.test.mjs
//
// This EXECUTES the real helpers from src/services/feedback.ts (which is kept
// import-free so type-stripping can load it — the `@/` alias is unresolvable
// here). Static/import-graph guards live in smoke.test.cjs.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
capContent,
sanitizeContext,
resolveErrorCode,
sendFeedback,
APP_ID,
FEEDBACK_HUB_URL,
MAX_CONTENT_LENGTH,
} from '../src/services/feedback.ts';
test('APP_ID is the exact repo name', () => {
assert.equal(APP_ID, 'simpl-liste');
});
test('capContent trims and caps at MAX_CONTENT_LENGTH', () => {
assert.equal(capContent(' hello '), 'hello');
assert.equal(capContent('x'.repeat(3000)).length, MAX_CONTENT_LENGTH);
assert.equal(capContent(''), '');
});
test('sanitizeContext keeps only whitelisted string keys', () => {
const out = sanitizeContext({
page: '/task/1',
locale: 'fr',
theme: 'dark',
secret: 'nope',
});
assert.deepEqual(out, { page: '/task/1', locale: 'fr', theme: 'dark' });
});
test('sanitizeContext truncates values to 500 chars', () => {
const out = sanitizeContext({ page: 'a'.repeat(900) });
assert.equal(out.page.length, 500);
});
test('sanitizeContext drops non-string values', () => {
const out = sanitizeContext({ page: '/x', viewport: 123 });
assert.deepEqual(out, { page: '/x' });
});
test('sanitizeContext returns undefined when empty or nullish', () => {
assert.equal(sanitizeContext(undefined), undefined);
assert.equal(sanitizeContext(null), undefined);
assert.equal(sanitizeContext({}), undefined);
assert.equal(sanitizeContext({ page: '' }), undefined);
});
test('resolveErrorCode maps statuses, defaulting to server_error', () => {
assert.equal(resolveErrorCode(400), 'invalid');
assert.equal(resolveErrorCode(429), 'rate_limit');
assert.equal(resolveErrorCode(500), 'server_error');
assert.equal(resolveErrorCode(413), 'server_error');
assert.equal(resolveErrorCode(404), 'server_error');
});
test('sendFeedback POSTs to the exact endpoint with the right body', async () => {
const calls = [];
const original = globalThis.fetch;
globalThis.fetch = async (url, opts) => {
calls.push({ url, opts });
return { ok: true, status: 201 };
};
try {
const result = await sendFeedback({
content: ' Great app ',
userId: null,
context: { page: '/settings' },
});
assert.deepEqual(result, { ok: true });
assert.equal(calls.length, 1);
assert.equal(calls[0].url, `${FEEDBACK_HUB_URL}/api/feedback`);
assert.equal(calls[0].opts.method, 'POST');
assert.equal(calls[0].opts.headers['Content-Type'], 'application/json');
const body = JSON.parse(calls[0].opts.body);
assert.equal(body.app_id, 'simpl-liste');
assert.equal(body.content, 'Great app'); // trimmed by capContent
assert.equal(body.user_id, null);
assert.deepEqual(body.context, { page: '/settings' });
} finally {
globalThis.fetch = original;
}
});
test('sendFeedback omits context when not opted in, and sends user_id when identified', async () => {
const captured = [];
const original = globalThis.fetch;
globalThis.fetch = async (_url, opts) => {
captured.push(JSON.parse(opts.body));
return { ok: true, status: 201 };
};
try {
await sendFeedback({ content: 'hi' });
assert.equal('context' in captured[0], false);
assert.equal(captured[0].user_id, null);
await sendFeedback({ content: 'hi', userId: 'logto-sub-123' });
assert.equal(captured[1].user_id, 'logto-sub-123');
} finally {
globalThis.fetch = original;
}
});
test('sendFeedback maps error statuses to codes', async () => {
const original = globalThis.fetch;
const origWarn = console.warn;
console.warn = () => {};
try {
for (const [status, code] of [
[400, 'invalid'],
[429, 'rate_limit'],
[500, 'server_error'],
[413, 'server_error'],
]) {
globalThis.fetch = async () => ({ ok: false, status });
const result = await sendFeedback({ content: 'hi' });
assert.deepEqual(result, { ok: false, code });
}
} finally {
globalThis.fetch = original;
console.warn = origWarn;
}
});
test('sendFeedback maps thrown fetch to network_error', async () => {
const original = globalThis.fetch;
const origWarn = console.warn;
console.warn = () => {};
globalThis.fetch = async () => {
throw new Error('offline');
};
try {
const result = await sendFeedback({ content: 'hi' });
assert.deepEqual(result, { ok: false, code: 'network_error' });
} finally {
globalThis.fetch = original;
console.warn = origWarn;
}
});

View file

@ -129,6 +129,293 @@ check('react-native-android-widget latency patch is wired up', () => {
assert.equal(pkg.scripts.postinstall, 'patch-package', 'postinstall must run patch-package');
});
// --- Feedback Hub client guards ---
// The feedback service is load-bearing on two axes the static check protects:
// (1) a wrong app_id/endpoint silently misroutes feedback in the shared hub,
// and (2) it MUST stay import-free — feedback.test.mjs loads it via node:test
// + TS type-stripping, which cannot resolve the `@/` alias or RN/Expo packages.
// Behavioral coverage (capContent/sanitizeContext/resolveErrorCode/sendFeedback)
// lives in tests/feedback.test.mjs; these guards lock the invariants that file
// can't assert about the source itself.
const FEEDBACK_SRC = 'src/services/feedback.ts';
check('feedback.ts targets the exact feedback.lacompagniemaximus.com /api/feedback endpoint', () => {
const src = fs.readFileSync(path.join(__dirname, '..', FEEDBACK_SRC), 'utf8');
assert.ok(
src.includes("'https://feedback.lacompagniemaximus.com'"),
'feedback hub URL missing or changed'
);
assert.ok(src.includes('/api/feedback'), 'endpoint path /api/feedback missing');
});
check("feedback.ts pins app_id to the exact literal 'simpl-liste'", () => {
const src = fs.readFileSync(path.join(__dirname, '..', FEEDBACK_SRC), 'utf8');
assert.match(src, /APP_ID\s*=\s*'simpl-liste'/, "APP_ID must be exactly 'simpl-liste'");
});
check('feedback.ts caps content at 2000 chars', () => {
const src = fs.readFileSync(path.join(__dirname, '..', FEEDBACK_SRC), 'utf8');
assert.match(src, /MAX_CONTENT_LENGTH\s*=\s*2000/, 'content cap must be 2000');
});
check('feedback.ts stays import-free (protects type-stripping test path)', () => {
const imports = staticImportsOf(FEEDBACK_SRC);
assert.deepEqual(
imports,
[],
`feedback.ts must have zero imports, found: ${imports.join(', ')}`
);
});
// --- Build toolchain guards ---
// A blanket `npm audit fix` (PR #118) once rewrote hundreds of lock entries:
// babel-preset-expo and @expo/metro-config lost their root hoisting and a
// second Expo SDK was nested inside Expo 54. The tests stayed green while the
// first Metro bundle failed. These checks lock the layout the build relies on.
const ROOT = path.resolve(__dirname, '..');
const ROOT_NODE_MODULES = path.join(ROOT, 'node_modules');
function majorOf(version) {
return version.split('.')[0];
}
// Every installed copy of `name`, at the root and nested under other packages.
function installedCopies(name, dir = ROOT_NODE_MODULES, found = []) {
const pkgJson = path.join(dir, name, 'package.json');
if (fs.existsSync(pkgJson)) {
found.push({ dir: path.dirname(pkgJson), version: require(pkgJson).version });
}
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
const pkgDirs = entry.name.startsWith('@')
? fs.readdirSync(path.join(dir, entry.name)).map((n) => path.join(dir, entry.name, n))
: [path.join(dir, entry.name)];
for (const pkgDir of pkgDirs) {
const nested = path.join(pkgDir, 'node_modules');
if (fs.existsSync(nested)) installedCopies(name, nested, found);
}
}
return found;
}
// babel.config.js and sql-transformer.js require these from the repo root.
for (const request of ['babel-preset-expo', '@expo/metro-config/babel-transformer']) {
check(`${request} resolves from the repo root node_modules`, () => {
const resolved = require.resolve(request, { paths: [ROOT] });
const pkgName = request.startsWith('@')
? request.split('/').slice(0, 2).join('/')
: request.split('/')[0];
const expectedDir = path.join(ROOT_NODE_MODULES, pkgName) + path.sep;
assert.ok(
resolved.startsWith(expectedDir),
`${request} resolved to ${resolved}, expected under ${expectedDir}`
);
});
}
check('a single Expo SDK major is installed (matches package.json)', () => {
const pkg = require('../package.json');
const expected = majorOf(pkg.dependencies.expo.replace(/^[~^]/, ''));
const copies = installedCopies('expo');
assert.ok(copies.length > 0, 'expo is not installed');
const strays = copies.filter((c) => majorOf(c.version) !== expected);
assert.deepEqual(
strays.map((c) => `${c.version} at ${path.relative(ROOT, c.dir)}`),
[],
`expected only expo ${expected}.x`
);
});
check('every installed metro copy honors the 0.83.x override pin', () => {
const copies = installedCopies('metro');
assert.ok(copies.length > 0, 'metro is not installed');
const strays = copies.filter((c) => !/^0\.83\./.test(c.version));
assert.deepEqual(
strays.map((c) => `${c.version} at ${path.relative(ROOT, c.dir)}`),
[],
'metro must stay on 0.83.x'
);
});
// @react-navigation/core <= 7.22.0 pulls query-string -> decode-uri-component
// <= 0.4.2 (GHSA-vcc3-ghjq-m6fr). 7.22.1 parses queries itself, so the deep
// link round trip must still carry query params through.
// Scope: this covers @react-navigation/core's own linking helpers only. The
// app's deep links (simplliste://task/<id>) are resolved by expo-router's fork
// (expo-router/build/fork/getStateFromPath*.js), which still uses
// query-string@7 (see #120) and is not exercised here.
check('@react-navigation/core is >= 7.22.1 and drops query-string', () => {
const core = require('@react-navigation/core/package.json');
const [major, minor, patch] = core.version.split('.').map(Number);
assert.ok(
major > 7 || (major === 7 && (minor > 22 || (minor === 22 && patch >= 1))),
`@react-navigation/core ${core.version} is vulnerable`
);
assert.ok(!('query-string' in (core.dependencies || {})), 'core still depends on query-string');
});
check('@react-navigation/core deep link round trip keeps query params', () => {
const { getStateFromPath, getPathFromState } = require('@react-navigation/core');
const config = { screens: { Task: 'task/:id' } };
const state = getStateFromPath('/task/abc?x=1&y=a%20b', config);
assert.deepEqual(state.routes[0].params, { id: 'abc', x: '1', y: 'a b' });
assert.equal(getPathFromState(state, config), '/task/abc?x=1&y=a%20b');
});
// --- Override floors for build-tool transitive deps (#124, #125) ---
// brace-expansion is overridden per major (version-scoped): a blanket override
// forces one major onto every minimatch and breaks minimatch@3 (#103). The
// floors below are the patched head of each major; any other major is either
// vulnerable (4.x) or unexpected, and must fail loudly.
function versionAtLeast(version, floor) {
const a = version.split('.').map(Number);
const b = floor.split('.').map(Number);
for (let i = 0; i < 3; i++) {
if (a[i] !== b[i]) return a[i] > b[i];
}
return true;
}
const BRACE_EXPANSION_FLOORS = { 1: '1.1.21', 2: '2.1.7', 5: '5.0.12' };
check('every installed brace-expansion copy is at or above its major floor', () => {
const copies = installedCopies('brace-expansion');
assert.ok(copies.length > 0, 'brace-expansion is not installed');
const strays = copies.filter((c) => {
const floor = BRACE_EXPANSION_FLOORS[majorOf(c.version)];
return !floor || !versionAtLeast(c.version, floor);
});
assert.deepEqual(
strays.map((c) => `${c.version} at ${path.relative(ROOT, c.dir)}`),
[],
`expected floors ${JSON.stringify(BRACE_EXPANSION_FLOORS)}`
);
});
check('every installed undici copy is >= 6.28.1', () => {
const copies = installedCopies('undici');
assert.ok(copies.length > 0, 'undici is not installed');
const strays = copies.filter((c) => !versionAtLeast(c.version, '6.28.1'));
assert.deepEqual(
strays.map((c) => `${c.version} at ${path.relative(ROOT, c.dir)}`),
[],
'undici must be >= 6.28.1'
);
});
// --- Override floors (#128, #129) ---
// http-cache-semantics: version-scoped to major 4, a single copy (@expo/ngrok > got).
// postcss-selector-parser: version-scoped to major 6 (tailwindcss@3 pipeline);
// any other major is unexpected and must fail loudly.
check('every installed http-cache-semantics copy is >= 4.3.0', () => {
const copies = installedCopies('http-cache-semantics');
assert.ok(copies.length > 0, 'http-cache-semantics is not installed');
const strays = copies.filter((c) => !versionAtLeast(c.version, '4.3.0'));
assert.deepEqual(
strays.map((c) => `${c.version} at ${path.relative(ROOT, c.dir)}`),
[],
'http-cache-semantics must be >= 4.3.0'
);
});
check('every installed postcss-selector-parser copy is 6.x and >= 6.1.3', () => {
const copies = installedCopies('postcss-selector-parser');
assert.ok(copies.length > 0, 'postcss-selector-parser is not installed');
const strays = copies.filter(
(c) => majorOf(c.version) !== '6' || !versionAtLeast(c.version, '6.1.3')
);
assert.deepEqual(
strays.map((c) => `${c.version} at ${path.relative(ROOT, c.dir)}`),
[],
'postcss-selector-parser must be 6.x >= 6.1.3'
);
});
// --- Override floors (#133, #134, #135) ---
// shell-quote: scoped to react-devtools-core (raised from #95's ^1.8.4).
// compression: scoped to @expo/cli (dev server middleware).
// source-map-js: version-scoped to major 1 (postcss, tailwindcss pipeline).
// Each floor is paired with a call to the API its consumer actually uses.
check('every installed shell-quote copy is >= 1.11.0 and still quotes/parses', () => {
const copies = installedCopies('shell-quote');
assert.ok(copies.length > 0, 'shell-quote is not installed');
const strays = copies.filter((c) => !versionAtLeast(c.version, '1.11.0'));
assert.deepEqual(
strays.map((c) => `${c.version} at ${path.relative(ROOT, c.dir)}`),
[],
'shell-quote must be >= 1.11.0'
);
for (const c of copies) {
const { quote, parse } = require(c.dir);
const where = `shell-quote ${c.version} at ${path.relative(ROOT, c.dir)}`;
assert.equal(quote(['code', '-g', 'a b.js:1']), "code -g 'a b.js:1'", `${where}: quote`);
assert.deepEqual(parse('a "b c" d'), ['a', 'b c', 'd'], `${where}: parse`);
}
});
check('every installed compression copy is >= 1.8.2 and builds a middleware', () => {
const copies = installedCopies('compression');
assert.ok(copies.length > 0, 'compression is not installed');
const strays = copies.filter((c) => !versionAtLeast(c.version, '1.8.2'));
assert.deepEqual(
strays.map((c) => `${c.version} at ${path.relative(ROOT, c.dir)}`),
[],
'compression must be >= 1.8.2'
);
for (const c of copies) {
const middleware = require(c.dir)();
assert.equal(typeof middleware, 'function', `compression ${c.version}: middleware`);
assert.equal(middleware.length, 3, `compression ${c.version}: (req, res, next) signature`);
}
});
check('every installed source-map-js copy is 1.x >= 1.2.2 and round-trips a mapping', () => {
const copies = installedCopies('source-map-js');
assert.ok(copies.length > 0, 'source-map-js is not installed');
const strays = copies.filter(
(c) => majorOf(c.version) !== '1' || !versionAtLeast(c.version, '1.2.2')
);
assert.deepEqual(
strays.map((c) => `${c.version} at ${path.relative(ROOT, c.dir)}`),
[],
'source-map-js must be 1.x >= 1.2.2'
);
for (const c of copies) {
const { SourceMapGenerator, SourceMapConsumer } = require(c.dir);
const gen = new SourceMapGenerator({ file: 'out.css' });
gen.addMapping({
source: 'in.css',
original: { line: 3, column: 2 },
generated: { line: 1, column: 0 },
});
const pos = new SourceMapConsumer(gen.toJSON()).originalPositionFor({ line: 1, column: 0 });
const where = `source-map-js ${c.version} at ${path.relative(ROOT, c.dir)}`;
assert.deepEqual([pos.source, pos.line, pos.column], ['in.css', 3, 2], where);
}
});
// The call that broke under the blanket override: each minimatch copy must
// still expand braces through its own brace-expansion major.
check('every installed minimatch copy still matches globs and braces', () => {
const copies = installedCopies('minimatch');
const majors = [...new Set(copies.map((c) => majorOf(c.version)))].sort();
assert.deepEqual(majors, ['10', '3', '9'], 'expected minimatch majors 3, 9 and 10');
for (const c of copies) {
const mod = require(c.dir);
const minimatch = mod.minimatch || mod; // v3 exports the function itself
const where = `minimatch ${c.version} at ${path.relative(ROOT, c.dir)}`;
assert.equal(minimatch('foo/bar.js', '**/*.js'), true, `${where}: globstar`);
assert.equal(minimatch('a/b.js', 'a/{b,c}.js'), true, `${where}: brace match`);
assert.equal(minimatch('a/d.js', 'a/{b,c}.js'), false, `${where}: brace miss`);
assert.equal(minimatch('f3.js', 'f{1..3}.js'), true, `${where}: numeric range`);
}
});
if (failed === 0) {
console.log('\nsmoke OK');
process.exit(0);