Compare commits

..

No commits in common. "master" and "v1.6.0" have entirely different histories.

16 changed files with 298 additions and 1259 deletions

View file

@ -15,39 +15,10 @@ user-invocable: true
1. Lire le `versionCode` actuel dans `app.json`
2. Incrementer `versionCode` (+1) — doit etre strictement superieur
3. Si demande par l'utilisateur : bumper `version` dans `app.json` + `package.json` (et `npm install` pour synchroniser le lockfile)
4. Commit : `chore: bump versionCode to <N>` (ou `chore: bump version to X.Y.Z (versionCode N)` si version aussi bumpee)
5. Push commit sur master, puis tag `vX.Y.Z` et `git push origin vX.Y.Z`
6. Build : `npx --yes eas-cli build --platform android --profile preview --non-interactive`
7. Quand le build est termine : telecharger l'APK, creer la release Forgejo, attacher l'APK
## Upload de l'APK — pattern resilient
```bash
# 1. Telecharger
APK_URL=$(npx --yes eas-cli build:view <build_id> --json | jq -r '.artifacts.buildUrl')
curl -L -o "/tmp/simpl-liste-vX.Y.Z.apk" "$APK_URL"
# 2. Creer la release
TOKEN=$(cat ~/.forgejo-token)
RELEASE_ID=$(curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
"https://git.lacompagniemaximus.com/api/v1/repos/maximus/simpl-liste/releases" \
-d '{"tag_name":"vX.Y.Z","name":"vX.Y.Z","body":"...","draft":false,"prerelease":false}' \
| jq -r '.id')
# 3. Attacher l'APK — RETRY sur 502/504 (variance reseau, pas un probleme infra)
for i in 1 2 3 4 5; do
http=$(curl -X POST -H "Authorization: token $TOKEN" \
"https://git.lacompagniemaximus.com/api/v1/repos/maximus/simpl-liste/releases/$RELEASE_ID/assets?name=simpl-liste-vX.Y.Z.apk" \
-F "attachment=@/tmp/simpl-liste-vX.Y.Z.apk" \
-o /tmp/asset-resp.json -w "%{http_code}" --max-time 300)
case "$http" in
201) echo "Upload OK"; break;;
502|504) echo "Attempt $i: gateway timeout, retry"; sleep 5;;
*) echo "Unexpected: HTTP $http"; cat /tmp/asset-resp.json; break;;
esac
done
```
3. Si demande par l'utilisateur : bumper `version` dans `app.json` + `package.json`
4. Commit : `chore: bump versionCode to <N>`
5. Build : `npx --yes eas-cli build --platform android --profile preview --non-interactive`
6. Quand le build est termine : creer une release Forgejo avec le lien APK
## Regles
@ -55,22 +26,3 @@ done
- `autoIncrement` dans eas.json ne s'applique qu'au profil `production`, pas `preview`
- Toujours utiliser `npx --yes eas-cli` (pas d'install globale)
- Ne JAMAIS `git push --tags` — push les tags un par un si necessaire
## Gotcha — Upload APK : 502/504 transitoire
**Symptome** : `curl -X POST ... -F "attachment=@<apk>"` retourne HTTP 502 ou 504 apres ~60s. La progression curl montre upload coupe a 50-90% de progression. Affecte typiquement les APK ~90MB+.
**Cause** : Traefik (devant Forgejo via Coolify) a un timeout de gateway de 60s sur la fin de requete. Quand la vitesse upload tombe sous ~1.5 MB/s (variance reseau classique sur connexions residentielles), un APK de 92 MB depasse le seuil et la requete est coupee. **Ce n'est pas un probleme de config infra** — c'est purement la vitesse upload du client a un instant donne.
**Fix** : retry. La 2e ou 3e tentative passe quand la bande passante remonte (j'ai mesure 1.3 MB/s puis 17 MB/s a quelques minutes d'intervalle). Le pattern `for i in 1..5` ci-dessus est suffisant — pas besoin de toucher a Traefik ni Forgejo.
**Verification que c'est bien le bon probleme** :
- L'APK precedent (~97 MB) a ete uploade avec succes les semaines passees → infra OK
- Un fichier de test de 11 octets sur la meme release passe en 0.1s → API OK
- HTTP 502 ou 504 apres 60s exactement → timeout gateway, pas erreur de logique
- Vitesse upload curl reportee < 1.5 MB/s confirme la cause
**Path API a connaitre** :
- Creer release : `POST /repos/{owner}/{repo}/releases` (renvoie `.id`)
- Attacher asset : `POST /repos/{owner}/{repo}/releases/{release_id}/assets?name=<filename>` (multipart `attachment`)
- Supprimer asset : `DELETE /repos/{owner}/{repo}/releases/{release_id}/assets/{asset_id}`**inclure le release_id dans le path**, pas seulement l'asset id, sinon HTTP 404

View file

@ -1,50 +0,0 @@
# SECURITY — simpl-liste
Rapport d'etat securite. Scanne quotidiennement par `defenseur-simpl-liste`
(04:15 UTC) dans `~/claude-code/defenseurs/`.
## CVE resolues
### 2026-04-24 — Overrides `@xmldom/xmldom` et `uuid`
Correction via `overrides` dans `package.json`. Aucune n'etait exploitable a
runtime dans l'APK : chaine concernee est build-time (iOS build via xcode,
Expo CLI, plist serialization) ou dev-tunnel (ngrok).
| CVE | Severite | Package | Fix version | Etat |
|---|---|---|---|---|
| GHSA-2v35-w6hq-6mfw | HIGH | @xmldom/xmldom | ^0.8.13 | Resolu |
| GHSA-f6ww-3ggp-fr8h | HIGH | @xmldom/xmldom | ^0.8.13 | Resolu |
| GHSA-x6wf-f3px-wcqx | HIGH | @xmldom/xmldom | ^0.8.13 | Resolu |
| GHSA-j759-j44w-7fr8 | HIGH | @xmldom/xmldom | ^0.8.13 | Resolu |
| GHSA-w5hq-g745-h8pq | MEDIUM | uuid | ^11.0.0 | Mitigation partielle (voir ci-dessous) |
## CVE residuelles
### GHSA-w5hq-g745-h8pq — `uuid` buffer bounds check
**Status** : mitigation partielle. L'advisory npm flag `uuid <14.0.0` meme apres
override a `^11.0.0`. Bug concerne `uuid.v3() / v5() / v6()` quand `buf` param
est fourni. Les consommateurs transitifs (`xcode`, `@expo/ngrok`) utilisent
uniquement `uuid.v4()` — donc **pas de code path vulnerable atteint en pratique**.
Non bump vers `^14.0.0` car ESM-only (casserait les imports CJS de xcode et
ngrok dans la chaine build iOS). Voir spec decision D3 pour details.
**Impact** : cascade via 18 advisories transitives (uuid → xcode/ngrok → expo/* → ...),
toutes remontant a la meme racine. Non-bloquant pour la production.
**Re-evaluation** : lors du prochain upgrade Expo SDK (quand xcode + ngrok
passeront a une version compatible avec `uuid@^14` ou retireront uuid).
## Procedure de scan manuel
```bash
cd ~/claude-code/defenseurs && npx tsx src/defenseur.ts defenseur-simpl-liste
```
## Politique de review
- **HIGH** : fix immediat via PR.
- **MEDIUM** : triage sous 7 jours, fix si exploitable.
- **LOW** : accepte tel quel, documente ici.

View file

@ -1,22 +0,0 @@
# STATE — simpl-liste
> Derniere MAJ : 2026-05-10 (par fix-issue #87)
## Position actuelle
Version 1.6.1 (versionCode 13). 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`).
## Decisions recentes
- 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).
## Blockers actifs
Aucun.

View file

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

View file

@ -1,7 +1,7 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { View, Text, Pressable, TextInput, useColorScheme, Alert, Animated, Easing } from 'react-native';
import { View, Text, Pressable, TextInput, useColorScheme, Alert, RefreshControl } from 'react-native';
import { useRouter } from 'expo-router';
import { Plus, ArrowUpDown, Filter, Download, Search, X, RefreshCw } from 'lucide-react-native';
import { Plus, ArrowUpDown, Filter, Download, Search, X } from 'lucide-react-native';
import { useTranslation } from 'react-i18next';
import * as Haptics from 'expo-haptics';
import DraggableFlatList, { RenderItemParams } from 'react-native-draggable-flatlist';
@ -45,7 +45,6 @@ export default function InboxScreen() {
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
const isDraggingRef = useRef(false);
const [refreshing, setRefreshing] = useState(false);
const spinAnim = useRef(new Animated.Value(0)).current;
const { sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, hasActiveFilters } = useTaskStore();
@ -73,29 +72,10 @@ export default function InboxScreen() {
}, [loadTasks]);
const handleRefresh = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
spinAnim.setValue(0);
Animated.loop(
Animated.timing(spinAnim, {
toValue: 1,
duration: 800,
easing: Easing.linear,
useNativeDriver: true,
})
).start();
try {
await loadTasks();
} finally {
setRefreshing(false);
spinAnim.stopAnimation();
}
}, [loadTasks, refreshing, spinAnim]);
const spin = spinAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
});
await loadTasks();
setRefreshing(false);
}, [loadTasks]);
const handleToggle = async (id: string) => {
await toggleComplete(id);
@ -191,11 +171,6 @@ export default function InboxScreen() {
</View>
) : (
<View className={`flex-row items-center justify-end border-b px-4 py-2 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}>
<Pressable onPress={handleRefresh} disabled={refreshing} className="mr-3 p-1">
<Animated.View style={{ transform: [{ rotate: spin }] }}>
<RefreshCw size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Animated.View>
</Pressable>
<Pressable onPress={() => setShowSearch(true)} className="mr-3 p-1">
<Search size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Pressable>
@ -233,6 +208,14 @@ export default function InboxScreen() {
onDragBegin={() => { isDraggingRef.current = true; }}
onDragEnd={handleDragEnd}
activationDistance={canDrag ? 0 : 10000}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.bleu.DEFAULT}
colors={[colors.bleu.DEFAULT]}
/>
}
/>
</GestureHandlerRootView>
)}

View file

@ -1,8 +1,8 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { View, Text, Pressable, TextInput, useColorScheme, Alert, Animated, Easing } from 'react-native';
import { View, Text, Pressable, TextInput, useColorScheme, Alert, RefreshControl } from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import {
ArrowLeft, Plus, ArrowUpDown, Filter, Download, Search, X, RefreshCw,
ArrowLeft, Plus, ArrowUpDown, Filter, Download, Search, X,
List, ShoppingCart, Briefcase, Home, Heart, Star, BookOpen,
GraduationCap, Dumbbell, Utensils, Plane, Music, Code, Wrench,
Gift, Camera, Palette, Dog, Leaf, Zap,
@ -62,7 +62,6 @@ export default function ListDetailScreen() {
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
const isDraggingRef = useRef(false);
const [refreshing, setRefreshing] = useState(false);
const spinAnim = useRef(new Animated.Value(0)).current;
const { sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, hasActiveFilters } = useTaskStore();
@ -98,29 +97,10 @@ export default function ListDetailScreen() {
}, [loadData]);
const handleRefresh = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
spinAnim.setValue(0);
Animated.loop(
Animated.timing(spinAnim, {
toValue: 1,
duration: 800,
easing: Easing.linear,
useNativeDriver: true,
})
).start();
try {
await loadData();
} finally {
setRefreshing(false);
spinAnim.stopAnimation();
}
}, [loadData, refreshing, spinAnim]);
const spin = spinAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
});
await loadData();
setRefreshing(false);
}, [loadData]);
const handleToggle = async (taskId: string) => {
await toggleComplete(taskId);
@ -219,11 +199,6 @@ export default function ListDetailScreen() {
</Text>
</View>
<View className="flex-row items-center">
<Pressable onPress={handleRefresh} disabled={refreshing} className="mr-3 p-1">
<Animated.View style={{ transform: [{ rotate: spin }] }}>
<RefreshCw size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Animated.View>
</Pressable>
<Pressable onPress={() => setShowSearch(true)} className="mr-3 p-1">
<Search size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Pressable>
@ -281,6 +256,14 @@ export default function ListDetailScreen() {
onDragBegin={() => { isDraggingRef.current = true; }}
onDragEnd={handleDragEnd}
activationDistance={canDrag ? 0 : 10000}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.bleu.DEFAULT}
colors={[colors.bleu.DEFAULT]}
/>
}
/>
</GestureHandlerRootView>
)}

474
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "simpl-liste",
"version": "1.6.3",
"version": "1.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "simpl-liste",
"version": "1.6.3",
"version": "1.5.1",
"dependencies": {
"@expo-google-fonts/inter": "^0.4.2",
"@expo/ngrok": "^4.1.3",
@ -17,25 +17,25 @@
"@react-navigation/native": "^7.1.8",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.45.2",
"expo": "~54.0.34",
"expo-auth-session": "~7.0.11",
"expo": "~54.0.33",
"expo-auth-session": "~7.0.10",
"expo-calendar": "~15.0.8",
"expo-constants": "~18.0.13",
"expo-crypto": "~15.0.9",
"expo-crypto": "~15.0.8",
"expo-file-system": "~19.0.21",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-intent-launcher": "~13.0.8",
"expo-linking": "~8.0.12",
"expo-linking": "~8.0.11",
"expo-localization": "~17.0.8",
"expo-notifications": "~0.32.17",
"expo-notifications": "~0.32.16",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-splash-screen": "~31.0.13",
"expo-sqlite": "~16.0.10",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.11",
"expo-web-browser": "~15.0.10",
"i18next": "^25.8.13",
"lucide-react-native": "^0.575.0",
"nativewind": "^4.2.2",
@ -2202,9 +2202,9 @@
}
},
"node_modules/@expo/fingerprint": {
"version": "0.15.5",
"resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.15.5.tgz",
"integrity": "sha512-mdVoAMcux1WlM6kd1RoWiHRNqKqS+J6mKmWQ/BKgeh937S/fcW58EE68O6nc4KDXtWi3PBeNHskOFcgyIuD4hw==",
"version": "0.15.4",
"resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.15.4.tgz",
"integrity": "sha512-eYlxcrGdR2/j2M6pEDXo9zU9KXXF1vhP+V+Tl+lyY+bU8lnzrN6c637mz6Ye3em2ANy8hhUR03Raf8VsT9Ogng==",
"license": "MIT",
"dependencies": {
"@expo/spawn-async": "^1.7.2",
@ -2214,7 +2214,7 @@
"getenv": "^2.0.0",
"glob": "^13.0.0",
"ignore": "^5.3.1",
"minimatch": "^10.2.2",
"minimatch": "^9.0.0",
"p-limit": "^3.1.0",
"resolve-from": "^5.0.0",
"semver": "^7.6.0"
@ -2224,9 +2224,9 @@
}
},
"node_modules/@expo/fingerprint/node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@ -2266,15 +2266,24 @@
}
},
"node_modules/@expo/json-file": {
"version": "10.0.14",
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.14.tgz",
"integrity": "sha512-yWwBFywFv+SxkJp/pIzzA416JVYflNUh7pqQzgaA6nXDqRyK7KfrqVzk8PdUfDnqbBcaZZxpzNssfQZzp5KHrA==",
"version": "10.0.8",
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.8.tgz",
"integrity": "sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.20.0",
"@babel/code-frame": "~7.10.4",
"json5": "^2.2.3"
}
},
"node_modules/@expo/json-file/node_modules/@babel/code-frame": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz",
"integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==",
"license": "MIT",
"dependencies": {
"@babel/highlight": "^7.10.4"
}
},
"node_modules/@expo/metro": {
"version": "54.2.0",
"resolved": "https://registry.npmjs.org/@expo/metro/-/metro-54.2.0.tgz",
@ -2298,9 +2307,9 @@
}
},
"node_modules/@expo/metro-config": {
"version": "54.0.15",
"resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.15.tgz",
"integrity": "sha512-SqIya4VZ9KHM1S9g+xR0A+QKw1Tfs7Gacx6bQNJ98vs4+O7I5+QP5mHZIB0QSZLUV8opiXebHYTiTu+0OAsIUw==",
"version": "54.0.14",
"resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.14.tgz",
"integrity": "sha512-hxpLyDfOR4L23tJ9W1IbJJsG7k4lv2sotohBm/kTYyiG+pe1SYCAWsRmgk+H42o/wWf/HQjE5k45S5TomGLxNA==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.20.0",
@ -2321,7 +2330,7 @@
"hermes-parser": "^0.29.1",
"jsc-safe-url": "^0.2.4",
"lightningcss": "^1.30.1",
"picomatch": "^4.0.3",
"minimatch": "^9.0.0",
"postcss": "~8.4.32",
"resolve-from": "^5.0.0"
},
@ -2334,18 +2343,6 @@
}
}
},
"node_modules/@expo/metro-config/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@expo/metro-runtime": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz",
@ -2537,6 +2534,16 @@
"win32"
]
},
"node_modules/@expo/ngrok/node_modules/uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
"deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
"license": "MIT",
"bin": {
"uuid": "bin/uuid"
}
},
"node_modules/@expo/ngrok/node_modules/yaml": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
@ -2547,24 +2554,25 @@
}
},
"node_modules/@expo/osascript": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.4.3.tgz",
"integrity": "sha512-wbuj3EebM7W9hN/Wp4xTzKd6rQ2zKJzAxkFxkOOwyysLp0HOAgQ4/5RINyoS241pZUX2rUHq7mAJ7pcCQ8U0Ow==",
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.3.8.tgz",
"integrity": "sha512-/TuOZvSG7Nn0I8c+FcEaoHeBO07yu6vwDgk7rZVvAXoeAK5rkA09jRyjYsZo+0tMEFaToBeywA6pj50Mb3ny9w==",
"license": "MIT",
"dependencies": {
"@expo/spawn-async": "^1.7.2"
"@expo/spawn-async": "^1.7.2",
"exec-async": "^2.2.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@expo/package-manager": {
"version": "1.10.5",
"resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.10.5.tgz",
"integrity": "sha512-nCP9Mebfl3jvOr0/P6VAuyah6PAtun+aihIL2zAtuE8uSe94JWkVZ7051i0MUVO+y3gFpBqnr8IIH5ch+VJjHA==",
"version": "1.9.10",
"resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.9.10.tgz",
"integrity": "sha512-axJm+NOj3jVxep49va/+L3KkF3YW/dkV+RwzqUJedZrv4LeTqOG4rhrCaCPXHTvLqCTDKu6j0Xyd28N7mnxsGA==",
"license": "MIT",
"dependencies": {
"@expo/json-file": "^10.0.14",
"@expo/json-file": "^10.0.8",
"@expo/spawn-async": "^1.7.2",
"chalk": "^4.0.0",
"npm-package-arg": "^11.0.0",
@ -2664,9 +2672,9 @@
"license": "MIT"
},
"node_modules/@expo/xcpretty": {
"version": "4.4.4",
"resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.4.tgz",
"integrity": "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==",
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.0.tgz",
"integrity": "sha512-o2qDlTqJ606h4xR36H2zWTywmZ3v3842K6TU8Ik2n1mfW0S580VHlt3eItVYdLYz+klaPp7CXqanja8eASZjRw==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/code-frame": "^7.20.0",
@ -3808,9 +3816,9 @@
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
"version": "0.8.12",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@ -4348,24 +4356,12 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/brace-expansion/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
"balanced-match": "^1.0.0"
}
},
"node_modules/braces": {
@ -5726,30 +5722,36 @@
"node": ">=6"
}
},
"node_modules/exec-async": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/exec-async/-/exec-async-2.2.0.tgz",
"integrity": "sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==",
"license": "MIT"
},
"node_modules/expo": {
"version": "54.0.34",
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.34.tgz",
"integrity": "sha512-XkVHguZZDC8BcTQxHAd14/TQFbDp1Wt0Z/KApO9t68Ll5A127hLCPzU+a9gytfCIiyL/V1IpF1vIcOLKEVAoNQ==",
"version": "54.0.33",
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.33.tgz",
"integrity": "sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.0",
"@expo/cli": "54.0.24",
"@expo/cli": "54.0.23",
"@expo/config": "~12.0.13",
"@expo/config-plugins": "~54.0.4",
"@expo/devtools": "0.1.8",
"@expo/fingerprint": "0.15.5",
"@expo/fingerprint": "0.15.4",
"@expo/metro": "~54.2.0",
"@expo/metro-config": "54.0.15",
"@expo/metro-config": "54.0.14",
"@expo/vector-icons": "^15.0.3",
"@ungap/structured-clone": "^1.3.0",
"babel-preset-expo": "~54.0.10",
"expo-asset": "~12.0.13",
"expo-asset": "~12.0.12",
"expo-constants": "~18.0.13",
"expo-file-system": "~19.0.22",
"expo-file-system": "~19.0.21",
"expo-font": "~14.0.11",
"expo-keep-awake": "~15.0.8",
"expo-modules-autolinking": "3.0.25",
"expo-modules-core": "3.0.30",
"expo-modules-autolinking": "3.0.24",
"expo-modules-core": "3.0.29",
"pretty-format": "^29.7.0",
"react-refresh": "^0.14.2",
"whatwg-url-without-unicode": "8.0.0-3"
@ -5788,13 +5790,13 @@
}
},
"node_modules/expo-asset": {
"version": "12.0.13",
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.13.tgz",
"integrity": "sha512-x/p7WvQUnkn6K43b9eL6SPeq5Vnf1E8BDe9bDrWrvMqzyUvJnUFvl+ctg3034s/+UHe7Ne2pAmc0+yzbl8CrDQ==",
"version": "12.0.12",
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz",
"integrity": "sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ==",
"license": "MIT",
"dependencies": {
"@expo/image-utils": "^0.8.8",
"expo-constants": "~18.0.13"
"expo-constants": "~18.0.12"
},
"peerDependencies": {
"expo": "*",
@ -5803,16 +5805,16 @@
}
},
"node_modules/expo-auth-session": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.11.tgz",
"integrity": "sha512-AhWtt/m9rb1Po77X/VBFbeE6UTgbm2vXP2iCblUSRsHCw2qD6lO0ulKUB8Xyxy9FtoI9yrNQ1iwCNgIIgo8VYQ==",
"version": "7.0.10",
"resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.10.tgz",
"integrity": "sha512-XDnKkudvhHSKkZfJ+KkodM+anQcrxB71i+h0kKabdLa5YDXTQ81aC38KRc3TMqmnBDHAu0NpfbzEVd9WDFY3Qg==",
"license": "MIT",
"dependencies": {
"expo-application": "~7.0.8",
"expo-constants": "~18.0.13",
"expo-crypto": "~15.0.9",
"expo-linking": "~8.0.12",
"expo-web-browser": "~15.0.11",
"expo-constants": "~18.0.11",
"expo-crypto": "~15.0.8",
"expo-linking": "~8.0.10",
"expo-web-browser": "~15.0.10",
"invariant": "^2.2.4"
},
"peerDependencies": {
@ -5845,9 +5847,9 @@
}
},
"node_modules/expo-crypto": {
"version": "15.0.9",
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.9.tgz",
"integrity": "sha512-SNWKa2fXx7v9gkp1h/7nqXY5XN7qgNDn3yRc2aO0gWGbeMbvob/haMxxsPFe9f51aqH5NjNCqHf2kvLhvAd8KQ==",
"version": "15.0.8",
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.8.tgz",
"integrity": "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0"
@ -5857,9 +5859,9 @@
}
},
"node_modules/expo-file-system": {
"version": "19.0.22",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz",
"integrity": "sha512-l9pgahSc7sJD0bP9vBNeXvZjy8QKDpVHVxWmei/ESQOrzmoj5BidziqLVsyZdxsi+PfdbTtttLTAmddH/JafYA==",
"version": "19.0.21",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
"integrity": "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
@ -5909,12 +5911,12 @@
}
},
"node_modules/expo-linking": {
"version": "8.0.12",
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.12.tgz",
"integrity": "sha512-FpXeIpFgZuxihwT9lBo86YD3y6LphBuAhN680MMxm/Y7fmsc57vimn2d3vFu68VI0+Z9w457t494mu2wvlgWTQ==",
"version": "8.0.11",
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz",
"integrity": "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==",
"license": "MIT",
"dependencies": {
"expo-constants": "~18.0.13",
"expo-constants": "~18.0.12",
"invariant": "^2.2.4"
},
"peerDependencies": {
@ -5936,9 +5938,9 @@
}
},
"node_modules/expo-modules-autolinking": {
"version": "3.0.25",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz",
"integrity": "sha512-YmHWctJlwvOuLZccg3cOXvSiXVJrPMKl7g2YR0YHWoGL9v2RvcmgaPJWPSLVW+voNEgEPsbo5UmUrAqbnYcBeg==",
"version": "3.0.24",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz",
"integrity": "sha512-TP+6HTwhL7orDvsz2VzauyQlXJcAWyU3ANsZ7JGL4DQu8XaZv/A41ZchbtAYLfozNA2Ya1Hzmhx65hXryBMjaQ==",
"license": "MIT",
"dependencies": {
"@expo/spawn-async": "^1.7.2",
@ -5952,9 +5954,9 @@
}
},
"node_modules/expo-modules-core": {
"version": "3.0.30",
"resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.30.tgz",
"integrity": "sha512-a6IrpAn/Jbmwxi9L+hMmXKpNqnkUpoF7WHOpn02rVLyax2J0gB1vvCVE5rNydplEnt41Q6WxQwvcOjZaIkcSUg==",
"version": "3.0.29",
"resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.29.tgz",
"integrity": "sha512-LzipcjGqk8gvkrOUf7O2mejNWugPkf3lmd9GkqL9WuNyeN2fRwU0Dn77e3ZUKI3k6sI+DNwjkq4Nu9fNN9WS7Q==",
"license": "MIT",
"dependencies": {
"invariant": "^2.2.4"
@ -5965,9 +5967,9 @@
}
},
"node_modules/expo-notifications": {
"version": "0.32.17",
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.17.tgz",
"integrity": "sha512-lwwzn7tImuzTzn9PAglZlS2VfZEvsfFGJTK9Eb8I4cqkGh2DI23YJFJH+WPEIu4QhDvk5JeBjklenJ8IZbmA4A==",
"version": "0.32.16",
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.16.tgz",
"integrity": "sha512-QQD/UA6v7LgvwIJ+tS7tSvqJZkdp0nCSj9MxsDk/jU1GttYdK49/5L2LvE/4U0H7sNBz1NZAyhDZozg8xgBLXw==",
"license": "MIT",
"dependencies": {
"@expo/image-utils": "^0.8.8",
@ -6247,9 +6249,9 @@
}
},
"node_modules/expo-server": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.6.tgz",
"integrity": "sha512-vb5TBtskvEdzYuW79lATXutOEBfW5m6U4EFpNjCVZTnI7S//SAsLQkYEpn+EDfn84m6VQfzSGkIVR6YPaScKFA==",
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz",
"integrity": "sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==",
"license": "MIT",
"engines": {
"node": ">=20.16.0"
@ -6304,9 +6306,9 @@
}
},
"node_modules/expo-web-browser": {
"version": "15.0.11",
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz",
"integrity": "sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==",
"version": "15.0.10",
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.10.tgz",
"integrity": "sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
@ -6314,9 +6316,9 @@
}
},
"node_modules/expo/node_modules/@expo/cli": {
"version": "54.0.24",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.24.tgz",
"integrity": "sha512-5xse1bEgnVUBhOrtttc6xTNJVvjyTRavpzuF0/0nuj+312vfSbk7EiRbG+xJ2pW/iZxnhLPJkFCrPYG0nmheAQ==",
"version": "54.0.23",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.23.tgz",
"integrity": "sha512-km0h72SFfQCmVycH/JtPFTVy69w6Lx1cHNDmfLfQqgKFYeeHTjx7LVDP4POHCtNxFP2UeRazrygJhlh4zz498g==",
"license": "MIT",
"dependencies": {
"@0no-co/graphql.web": "^1.0.8",
@ -6328,7 +6330,7 @@
"@expo/image-utils": "^0.8.8",
"@expo/json-file": "^10.0.8",
"@expo/metro": "~54.2.0",
"@expo/metro-config": "~54.0.15",
"@expo/metro-config": "~54.0.14",
"@expo/osascript": "^2.3.8",
"@expo/package-manager": "^1.9.10",
"@expo/plist": "^0.4.8",
@ -6351,16 +6353,16 @@
"connect": "^3.7.0",
"debug": "^4.3.4",
"env-editor": "^0.4.1",
"expo-server": "^1.0.6",
"expo-server": "^1.0.5",
"freeport-async": "^2.0.0",
"getenv": "^2.0.0",
"glob": "^13.0.0",
"lan-network": "^0.2.1",
"lan-network": "^0.1.6",
"minimatch": "^9.0.0",
"node-forge": "^1.3.3",
"npm-package-arg": "^11.0.0",
"ora": "^3.4.0",
"picomatch": "^4.0.3",
"picomatch": "^3.0.1",
"pretty-bytes": "^5.6.0",
"pretty-format": "^29.7.0",
"progress": "^2.0.3",
@ -6400,15 +6402,6 @@
}
}
},
"node_modules/expo/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/expo/node_modules/ci-info": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
@ -6424,37 +6417,22 @@
"node": ">=8"
}
},
"node_modules/expo/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/expo/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.2.tgz",
"integrity": "sha512-cfDHL6LStTEKlNilboNtobT/kEa30PtAf2Q1OgszfrG/rpVl1xaFWT9ktfkS306GmHgmnad1Sw4wabhlvFtsTw==",
"license": "MIT",
"engines": {
"node": ">=12"
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/expo/node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@ -6464,9 +6442,9 @@
}
},
"node_modules/expo/node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@ -6867,6 +6845,42 @@
"node": ">=10.13.0"
}
},
"node_modules/glob/node_modules/balanced-match": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz",
"integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==",
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/global-dirs": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz",
@ -7743,9 +7757,9 @@
}
},
"node_modules/lan-network": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.2.1.tgz",
"integrity": "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==",
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.1.7.tgz",
"integrity": "sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ==",
"license": "MIT",
"bin": {
"lan-network": "dist/lan-network-cli.js"
@ -7786,9 +7800,9 @@
"license": "MIT"
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
"integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==",
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
@ -7801,23 +7815,23 @@
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
"lightningcss-android-arm64": "1.31.1",
"lightningcss-darwin-arm64": "1.31.1",
"lightningcss-darwin-x64": "1.31.1",
"lightningcss-freebsd-x64": "1.31.1",
"lightningcss-linux-arm-gnueabihf": "1.31.1",
"lightningcss-linux-arm64-gnu": "1.31.1",
"lightningcss-linux-arm64-musl": "1.31.1",
"lightningcss-linux-x64-gnu": "1.31.1",
"lightningcss-linux-x64-musl": "1.31.1",
"lightningcss-win32-arm64-msvc": "1.31.1",
"lightningcss-win32-x64-msvc": "1.31.1"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz",
"integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==",
"cpu": [
"arm64"
],
@ -7835,9 +7849,9 @@
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz",
"integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==",
"cpu": [
"arm64"
],
@ -7855,9 +7869,9 @@
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz",
"integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==",
"cpu": [
"x64"
],
@ -7875,9 +7889,9 @@
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz",
"integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==",
"cpu": [
"x64"
],
@ -7895,9 +7909,9 @@
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz",
"integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==",
"cpu": [
"arm"
],
@ -7915,9 +7929,9 @@
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz",
"integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==",
"cpu": [
"arm64"
],
@ -7935,9 +7949,9 @@
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz",
"integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==",
"cpu": [
"arm64"
],
@ -7955,9 +7969,9 @@
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz",
"integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==",
"cpu": [
"x64"
],
@ -7975,9 +7989,9 @@
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz",
"integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==",
"cpu": [
"x64"
],
@ -7995,9 +8009,9 @@
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz",
"integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==",
"cpu": [
"arm64"
],
@ -8015,9 +8029,9 @@
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz",
"integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==",
"cpu": [
"x64"
],
@ -8639,15 +8653,15 @@
}
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "BlueOak-1.0.0",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^5.0.5"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@ -8840,9 +8854,9 @@
}
},
"node_modules/npm-package-arg/node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@ -9319,9 +9333,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.12",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"version": "8.4.49",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
"integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
"funding": [
{
"type": "opencollective",
@ -9338,7 +9352,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.7",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@ -11358,9 +11372,9 @@
}
},
"node_modules/tar": {
"version": "7.5.15",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
"integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==",
"version": "7.5.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
@ -11664,9 +11678,9 @@
}
},
"node_modules/undici": {
"version": "6.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
"integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==",
"version": "6.24.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
"integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
"license": "MIT",
"engines": {
"node": ">=18.17"
@ -11859,16 +11873,12 @@
}
},
"node_modules/uuid": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz",
"integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==",
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
"uuid": "dist/bin/uuid"
}
},
"node_modules/validate-npm-package-name": {
@ -12200,9 +12210,9 @@
}
},
"node_modules/wonka": {
"version": "6.3.6",
"resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.6.tgz",
"integrity": "sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==",
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.5.tgz",
"integrity": "sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw==",
"license": "MIT"
},
"node_modules/wrap-ansi": {

View file

@ -1,10 +1,9 @@
{
"name": "simpl-liste",
"main": "index.js",
"version": "1.6.4",
"version": "1.6.0",
"scripts": {
"start": "expo start",
"test": "node tests/smoke.test.cjs",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
@ -19,25 +18,25 @@
"@react-navigation/native": "^7.1.8",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.45.2",
"expo": "~54.0.34",
"expo-auth-session": "~7.0.11",
"expo": "~54.0.33",
"expo-auth-session": "~7.0.10",
"expo-calendar": "~15.0.8",
"expo-constants": "~18.0.13",
"expo-crypto": "~15.0.9",
"expo-crypto": "~15.0.8",
"expo-file-system": "~19.0.21",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-intent-launcher": "~13.0.8",
"expo-linking": "~8.0.12",
"expo-linking": "~8.0.11",
"expo-localization": "~17.0.8",
"expo-notifications": "~0.32.17",
"expo-notifications": "~0.32.16",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-splash-screen": "~31.0.13",
"expo-sqlite": "~16.0.10",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.11",
"expo-web-browser": "~15.0.10",
"i18next": "^25.8.13",
"lucide-react-native": "^0.575.0",
"nativewind": "^4.2.2",
@ -65,10 +64,7 @@
"typescript": "~5.9.2"
},
"overrides": {
"esbuild": "^0.25.0",
"@xmldom/xmldom": "^0.8.13",
"uuid": "^11.1.1",
"postcss": "^8.5.10"
"esbuild": "^0.25.0"
},
"private": true
}

View file

@ -1,603 +0,0 @@
# Spec — Simpl-Liste Web
> Date: 2026-04-06
> Projet: simpl-liste
> Statut: Draft
> Dependance: Logto IdP (deploye et operationnel)
## Contexte
Simpl-Liste est actuellement une app mobile React Native/Expo avec stockage SQLite local (offline-first, sans compte). L'objectif est de rendre l'application disponible via le web, integree au systeme d'authentification Compte Maximus (Logto), avec synchronisation bidirectionnelle entre le mobile et le web.
L'app mobile continue de fonctionner sans compte. Le login debloque la sync ; il reste optionnel.
## Objectif
Permettre aux utilisateurs connectes avec un Compte Maximus de gerer leurs listes et taches depuis `liste.lacompagniemaximus.com`, avec sync hybride : temps reel sur le web (WebSocket), polling sur le mobile. Parite fonctionnelle complete avec le mobile (hors widgets, notifications push, et sync calendrier).
## Scope
### IN
- Frontend web Next.js App Router dans `simpl-liste/web/`
- API REST backend (route handlers Next.js) pour CRUD listes, taches, tags, sous-taches
- WebSocket server pour notifications temps reel sur le web
- Schema PostgreSQL (`sl_` prefix) avec Drizzle ORM (`drizzle-orm/pg-core`)
- Auth via Logto (Compte Maximus) — SDK `@logto/next`
- Sync hybride : WebSocket web + polling mobile
- Login optionnel dans l'app mobile avec choix de migration des donnees locales
- Types TypeScript partages entre mobile et web (`src/shared/`)
- i18n FR/EN (francais par defaut)
- Dark mode (light/dark/system)
- Responsive design (utilisable sur mobile)
- Deploiement sur Coolify (`liste.lacompagniemaximus.com`)
### OUT (explicitement exclu)
- Widgets Android (specifique mobile)
- Notifications push (specifique mobile)
- Sync calendrier systeme (specifique mobile via expo-calendar)
- Export ICS depuis le web (future phase)
- Mode offline pour le web (connexion requise)
- Partage de listes entre utilisateurs (future phase)
- App iOS (future phase)
## Design
### Architecture globale
```
simpl-liste/
├── app/ # App mobile React Native (existant)
├── src/ # Code mobile (existant)
│ ├── shared/ # NOUVEAU — types, constantes, logique partagee
│ │ ├── types.ts # Task, List, Tag, TaskFilters, etc.
│ │ ├── colors.ts # Palette (extrait de theme/colors.ts)
│ │ ├── priority.ts # Helpers priorite (extrait de lib/priority.ts)
│ │ └── recurrence.ts # Calcul recurrence (extrait de lib/recurrence.ts)
│ └── ...
└── web/ # NOUVEAU — App Next.js
├── package.json
├── next.config.ts
├── server.ts # Custom server (Next.js + WebSocket)
├── Dockerfile
├── src/db/
│ ├── schema.ts # Schema PostgreSQL Drizzle (sl_ tables)
│ ├── client.ts # Connexion pg + drizzle instance
│ └── migrations/ # Migrations SQL (drizzle-kit generate)
├── src/
│ ├── app/ # App Router (pages, layouts, API routes)
│ │ ├── layout.tsx
│ │ ├── page.tsx # Redirect vers /lists ou /auth
│ │ ├── auth/ # Login via Logto
│ │ ├── api/ # Route handlers REST
│ │ │ ├── lists/
│ │ │ ├── tasks/
│ │ │ ├── tags/
│ │ │ └── sync/
│ │ └── (app)/ # Pages authentifiees
│ │ ├── layout.tsx # Sidebar listes + main area
│ │ ├── inbox/
│ │ └── lists/[id]/
│ ├── lib/
│ │ ├── auth.ts # Logto middleware + session
│ │ ├── db.ts # Re-export drizzle client
│ │ └── ws.ts # WebSocket server + broadcast
│ ├── components/ # Composants React web
│ └── i18n/ # next-intl ou i18next
└── tailwind.config.ts # Palette partagee depuis src/shared/colors.ts
```
### ORM unifie — Drizzle partout
Le projet utilise **Drizzle ORM** sur les deux plateformes :
- **Mobile** : `drizzle-orm/sqlite-core` + `expo-sqlite` (existant)
- **Web/serveur** : `drizzle-orm/pg-core` + `pg` (nouveau)
Un seul ORM a maitriser, un seul outil de migration (`drizzle-kit`), pas de codegen (contrairement a Prisma). Les schemas SQLite et PostgreSQL partagent la meme structure de colonnes — les types inferes par Drizzle (`$inferSelect`) sont directement compatibles.
Le dossier `src/shared/types.ts` exporte les types metier communs (filtres, tri, recurrence, priorites). Les types des entites (Task, List, Tag) sont inferes depuis les schemas Drizzle respectifs — pas besoin d'une couche de mapping manuelle.
```typescript
// src/shared/types.ts — types partages (filtres, enums, helpers)
export type RecurrenceType = 'daily' | 'weekly' | 'monthly' | 'yearly';
export type Priority = 0 | 1 | 2 | 3;
export type SortBy = 'position' | 'priority' | 'dueDate' | 'title' | 'createdAt';
export type SortOrder = 'asc' | 'desc';
export type FilterCompleted = 'all' | 'active' | 'completed';
export type FilterDueDate = 'all' | 'today' | 'week' | 'overdue' | 'noDate';
export interface TaskFilters { sortBy: SortBy; sortOrder: SortOrder; /* ... */ }
// Les types des entites sont inferes depuis les schemas Drizzle de chaque plateforme :
// Mobile : typeof tasks.$inferSelect (depuis src/db/schema.ts, sqlite-core)
// Web : typeof slTasks.$inferSelect (depuis web/src/db/schema.ts, pg-core)
```
### Identite utilisateur — Compte Maximus
Le `userId` stocke dans les tables PostgreSQL est le **`sub` claim du JWT Logto**. Ce choix est delibere : Logto est le systeme d'identite centralise de La Compagnie Maximus. Toutes les apps actuelles et futures (la-suite-booking, famille-website, etc.) migreront vers Logto comme IdP unique. Utiliser le `sub` comme identifiant commun garantit la compatibilite cross-app et permettra un futur dashboard Compte Maximus unifie.
### Schema PostgreSQL (Drizzle pg-core)
Base sur le schema SQLite existant (`src/db/schema.ts`), avec ajout de `userId` (= `sub` Logto) pour l'isolation multi-utilisateur et `deletedAt` pour le soft-delete (sync). Structure quasi identique au schema mobile — meme ORM, memes conventions.
```typescript
// web/src/db/schema.ts
import { pgTable, uuid, text, integer, boolean, timestamp, primaryKey, index } from 'drizzle-orm/pg-core';
export const slLists = pgTable('sl_lists', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
name: text('name').notNull(),
color: text('color'),
icon: text('icon'),
position: integer('position').notNull().default(0),
isInbox: boolean('is_inbox').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
}, (table) => [
index('idx_sl_lists_user').on(table.userId),
]);
export const slTasks = pgTable('sl_tasks', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
title: text('title').notNull(),
notes: text('notes'),
completed: boolean('completed').notNull().default(false),
completedAt: timestamp('completed_at', { withTimezone: true }),
priority: integer('priority').notNull().default(0), // 0=none, 1=low, 2=medium, 3=high
dueDate: timestamp('due_date', { withTimezone: true }),
listId: uuid('list_id').notNull().references(() => slLists.id, { onDelete: 'cascade' }),
parentId: uuid('parent_id').references((): any => slTasks.id, { onDelete: 'cascade' }),
position: integer('position').notNull().default(0),
recurrence: text('recurrence'), // 'daily'|'weekly'|'monthly'|'yearly'
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
}, (table) => [
index('idx_sl_tasks_user').on(table.userId),
index('idx_sl_tasks_list').on(table.listId),
index('idx_sl_tasks_parent').on(table.parentId),
]);
export const slTags = pgTable('sl_tags', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
name: text('name').notNull(),
color: text('color').notNull().default('#4A90A4'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
}, (table) => [
index('idx_sl_tags_user').on(table.userId),
]);
export const slTaskTags = pgTable('sl_task_tags', {
taskId: uuid('task_id').notNull().references(() => slTasks.id, { onDelete: 'cascade' }),
tagId: uuid('tag_id').notNull().references(() => slTags.id, { onDelete: 'cascade' }),
}, (table) => [
primaryKey({ columns: [table.taskId, table.tagId] }),
]);
```
Notes :
- `deletedAt` (soft-delete) sur lists, tasks, tags — necessaire pour la sync (les suppressions doivent etre propagees aux clients)
- `calendar_event_id` omis (specifique mobile)
- `updatedAt` sert de vecteur de version pour le last-write-wins
- La table `sl_tags` n'a pas de `updatedAt` dans le schema SQLite mobile — a ajouter cote mobile pour la sync
- Le schema PostgreSQL est structurellement identique au schema SQLite mobile, avec `userId` et `deletedAt` en plus — memes noms de colonnes, memes types logiques
### API REST
Base URL: `https://liste.lacompagniemaximus.com/api`
Auth: Session Logto (cookie) pour le web, Bearer JWT pour le mobile
#### Listes
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/api/lists` | Toutes les listes de l'utilisateur |
| POST | `/api/lists` | Creer une liste |
| PUT | `/api/lists/:id` | Modifier une liste |
| DELETE | `/api/lists/:id` | Soft-delete (cascade taches) |
| PUT | `/api/lists/reorder` | Reordonner (batch positions) |
#### Taches
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/api/lists/:listId/tasks` | Taches d'une liste (filtres: completed, priority, dueDate, tags) |
| GET | `/api/tasks/:id/subtasks` | Sous-taches |
| POST | `/api/tasks` | Creer une tache |
| PUT | `/api/tasks/:id` | Modifier (titre, notes, completed, priorite, dueDate, listId, position, recurrence) |
| DELETE | `/api/tasks/:id` | Soft-delete (cascade sous-taches) |
| PUT | `/api/tasks/reorder` | Reordonner dans une liste |
#### Tags
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/api/tags` | Tous les tags de l'utilisateur |
| POST | `/api/tags` | Creer un tag |
| PUT | `/api/tags/:id` | Modifier un tag |
| DELETE | `/api/tags/:id` | Soft-delete |
| POST | `/api/tasks/:id/tags` | Assigner des tags a une tache |
| DELETE | `/api/tasks/:taskId/tags/:tagId` | Retirer un tag |
#### Sync (mobile)
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/api/sync?since=<ISO timestamp>` | Changements serveur depuis timestamp (inclut soft-deletes) |
| POST | `/api/sync` | Push batch de changements locaux (avec idempotency keys) |
#### WebSocket (web)
| Methode | Endpoint | Description |
|---------|----------|-------------|
| POST | `/api/ws-ticket` | Generer un ticket ephemere (nonce, TTL 30s) pour le handshake WS |
| WS | `/ws?ticket=<nonce>` | Connexion WebSocket (ticket a usage unique) |
### Sync Strategy — Hybride
#### Web : WebSocket temps reel
- Custom server Next.js (`server.ts`) qui attache un serveur `ws` sur le meme port HTTP (path `/ws`)
- A chaque mutation via l'API, le serveur broadcast un message aux WebSocket connectes du meme `userId`
- Le client web ecoute et rafraichit les donnees affectees (pas de re-fetch complet, notification granulaire par entity type + id)
- Reconnexion automatique avec backoff exponentiel cote client
- Heartbeat toutes les 30s pour detecter les connexions mortes
```typescript
// Message WebSocket — notification seulement, pas de payload (securite)
type WsMessage =
| { type: 'sync'; entity: 'list' | 'task' | 'tag'; action: 'create' | 'update' | 'delete'; id: string }
| { type: 'auth_expired' } // session expiree, le client doit se reconnecter
```
#### Mobile : polling periodique
- Sync au lancement de l'app (si connecte)
- Sync toutes les 2 minutes en foreground
- Sync au retour du background (app state change)
- Indicateur visuel de statut sync (icone dans le header)
#### Resolution de conflits
- **Last-write-wins** base sur `updatedAt` — le timestamp le plus recent gagne
- En cas d'egalite exacte (improbable), le serveur gagne
- Les suppressions (soft-delete) sont prioritaires : si un client supprime et l'autre modifie, la suppression gagne
#### Flux de sync mobile
1. Le mobile envoie `POST /api/sync` avec tous les changements locaux depuis `lastSyncAt`
2. Le serveur applique les changements (LWW), retourne les conflits resolus
3. Le mobile fait `GET /api/sync?since=lastSyncAt` pour recuperer les changements serveur
4. Le mobile applique les changements serveur dans SQLite
5. `lastSyncAt` est mis a jour dans AsyncStorage
#### Premiere connexion — Migration des donnees locales
Au premier login dans l'app mobile, une alerte propose :
1. **"Fusionner mes taches"** — Upload toutes les donnees locales vers le serveur, puis sync bidirectionnel
2. **"Repartir du serveur"** — Ecrase les donnees locales avec celles du serveur (si le compte a deja des donnees web)
**Reconciliation de l'Inbox** : L'Inbox mobile a un ID hardcode (`00000000-0000-0000-0000-000000000001`). Le serveur cree une Inbox avec un UUID genere. Lors du merge :
1. Le serveur cree l'Inbox du user si elle n'existe pas
2. Le mobile remap l'ID hardcode vers l'ID Inbox serveur dans toutes les taches locales
3. Push les taches avec le nouvel ID
4. Mettre a jour l'ID Inbox local pour matcher le serveur
#### Outbox pattern — file d'attente locale
Les changements locaux sont d'abord ecrits dans une table SQLite `sync_outbox` avant d'etre envoyes au serveur :
```sql
CREATE TABLE sync_outbox (
id TEXT PRIMARY KEY, -- UUID (= idempotency key)
entity_type TEXT NOT NULL, -- 'list' | 'task' | 'tag' | 'task_tag'
entity_id TEXT NOT NULL, -- ID de l'entite modifiee
action TEXT NOT NULL, -- 'create' | 'update' | 'delete'
payload TEXT NOT NULL, -- JSON de l'operation
created_at TEXT NOT NULL, -- ISO 8601
synced_at TEXT -- NULL tant que pas sync, ISO 8601 apres
);
```
Flux :
1. Chaque mutation locale ecrit dans la table metier ET dans `sync_outbox`
2. Le sync service lit les entries `WHERE synced_at IS NULL`, les envoie en batch via `POST /api/sync`
3. Les entries synced sont marquees (`synced_at = now()`)
4. Les entries de plus de 7 jours sont purgees
5. Si le serveur est inaccessible, l'outbox s'accumule — les changements ne sont jamais perdus
### Auth
#### Web (Next.js)
- SDK `@logto/next` pour l'auth server-side
- Middleware Next.js qui protege toutes les routes sous `/(app)/`
- Session cookie securisee (HttpOnly, SameSite=Strict)
- Extraction du `userId` depuis les claims JWT Logto
- Verification optionnelle du claim `apps.simpl-liste` pour l'acces
#### Mobile (React Native)
- SDK `@logto/rn` pour le flow OAuth2/OIDC
- Bouton "Se connecter" dans Settings > Compte
- Token JWT stocke securise (expo-secure-store)
- Le token est envoye en header `Authorization: Bearer <jwt>` pour les appels API
- Mode deconnecte : l'app fonctionne normalement sans token, la sync est desactivee
### Frontend Web — UX
#### Layout principal
```
┌─────────────────────────────────────────────────┐
│ Simpl-Liste [FR] [🌙] [👤] │
├──────────┬──────────────────────────────────────┤
│ │ │
│ 📥 Inbox │ ☐ Acheter du lait ⚡ Haute │
│ 📋 Courses│ ☐ Appeler le dentiste 📅 Lun │
│ 🏠 Maison │ ☑ Faire le menage ✓ Fait │
│ + Liste │ ☐ Reparer le robinet │
│ │ │
│ Tags │ [+ Nouvelle tache] │
│ 🔵 Urgent│ │
│ 🟢 Perso │ Filtres: [Actives ▾] [Priorite ▾] │
│ │ Tri: [Position ▾] │
├──────────┴──────────────────────────────────────┤
│ ● Connecte — Sync OK │
└─────────────────────────────────────────────────┘
```
- **Sidebar gauche** : listes (avec couleur/icone), section tags, bouton + pour creer
- **Zone principale** : taches de la liste selectionnee, avec filtres et tri en haut
- **Barre d'etat** : statut de connexion et derniere sync
- Edition inline des taches (titre, completion, priorite) — modal pour les details complets (notes, date, tags, sous-taches, recurrence)
- Responsive : sidebar se replie en hamburger menu sur mobile web
### Securite
#### Transport et authentification
- TLS obligatoire (HTTPS/WSS via Caddy) — jamais de connexion non chiffree
- Toutes les requetes API authentifiees (session cookie web / JWT Bearer mobile)
- Session cookie web : `HttpOnly`, `Secure`, `SameSite=Strict`
- JWT mobile : access token court (15 min) + refresh token avec rotation (chaque refresh invalide l'ancien)
- Validation JWT systematique sur chaque requete : signature, `exp`, `aud`, `iss`
#### Stockage des tokens mobile
- Adaptateur de stockage custom pour `@logto/rn` basé sur `expo-secure-store` (Keychain iOS / Keystore Android)
- Ne JAMAIS stocker de tokens dans AsyncStorage (non chiffre, lisible sur appareil roote)
- Ne pas inclure de donnees sensibles dans le payload JWT (le payload est encode en base64, pas chiffre)
#### WebSocket — Anti-CSWSH (Cross-Site WebSocket Hijacking)
- Authentification par **ticket ephemere** (pas par cookie) :
1. Le client appelle `POST /api/ws-ticket` (authentifie par session)
2. Le serveur genere un nonce a usage unique (TTL 30s), stocke en memoire
3. Le client se connecte a `wss://host/ws?ticket=<nonce>`
4. Le serveur valide et invalide le ticket au handshake (usage unique)
- Validation du header `Origin` contre une allowlist (`liste.lacompagniemaximus.com`)
- Re-validation de la session toutes les 15 minutes — fermer la connexion si expiree, envoyer `{ type: 'auth_expired' }` au client
- Ne pas logger les query params sur la route `/ws` (eviter fuite du ticket dans les access logs)
#### Isolation des donnees et autorisation
- Isolation par `userId` sur chaque requete (`WHERE userId = ?`)
- **BOLA prevention sur le sync batch** : pour chaque operation dans `POST /api/sync`, verifier que l'entite appartient au `userId` (`WHERE id = ? AND userId = ?`). Rejeter le batch entier si une seule verification echoue.
- Schemas Zod **strict** sur chaque endpoint — whitelist explicite des champs modifiables
- Ne jamais permettre au client de modifier `userId`, `createdAt`, `deletedAt` via l'API
#### Anti-replay sur les endpoints sync
- **Idempotency keys** : chaque operation dans le batch porte un UUID genere par le client. Le serveur stocke les IDs traites (TTL 24h) et retourne le meme resultat si resoumis.
- **Fenetre temporelle** : rejeter les requetes dont le timestamp depasse +/- 5 minutes
#### Rate limiting granulaire
| Endpoint | Limite par user | Raison |
|----------|----------------|--------|
| `POST /api/sync` | 10/min | Operation lourde (batch) |
| `POST /api/ws-ticket` | 10/min | Prevenir brute-force de tickets |
| `POST /api/lists`, `POST /api/tasks`, `POST /api/tags` | 30/min | Creation |
| `GET /api/*` | 200/min | Lecture |
| Connexions WebSocket | 5/min | Prevenir flood de reconnexions |
#### Messages WebSocket
- Envoyer seulement le type d'entite + id dans les messages WS (pas le contenu des taches)
- Le client re-fetch les donnees modifiees via l'API REST (securisee)
- Evite la fuite de donnees si la connexion WS est compromise
#### Soft-delete et retention
- Purge automatique des enregistrements soft-deleted apres 30 jours (cron job)
- Les clients qui n'ont pas sync depuis 30+ jours effectuent un full sync au lieu d'un delta
- Politique de retention documentee dans les conditions d'utilisation
#### Input validation
- Validation/sanitization (zod) sur tous les champs texte
- Protection XSS : echapper le contenu utilisateur a l'affichage (React le fait par defaut)
- CSRF protection sur les mutations (cookie `SameSite=Strict`)
## Plan de travail
### Issue 1 — Types partages et refactoring mobile [type:task]
Dependances : aucune
- [ ] Creer `src/shared/types.ts` — exporter Task, List, Tag, TaskFilters, SortBy, etc.
- [ ] Creer `src/shared/colors.ts` — extraire la palette de `theme/colors.ts`
- [ ] Creer `src/shared/priority.ts` — extraire de `lib/priority.ts`
- [ ] Creer `src/shared/recurrence.ts` — extraire de `lib/recurrence.ts`
- [ ] Refactorer le code mobile pour importer depuis `shared/`
- [ ] Ajouter `updatedAt` au schema `tags` dans le mobile (migration Drizzle)
### Issue 2 — Setup projet web + schema PostgreSQL [type:task]
Dependances : aucune (parallele avec Issue 1)
- [ ] Init Next.js App Router dans `web/` avec TypeScript, Tailwind, Drizzle ORM (`drizzle-orm/pg-core` + `pg`)
- [ ] Schema Drizzle PostgreSQL (sl_lists, sl_tasks, sl_tags, sl_task_tags) — userId = `sub` Logto
- [ ] Migration initiale + seed (inbox par defaut par user)
- [ ] Endpoint `/api/health` (status API, latence DB, connexions WS actives)
- [ ] Dockerfile + config Coolify pour `liste.lacompagniemaximus.com` (verifier support WS dans Caddy)
- [ ] Configurer Logto app pour le web (redirect URIs, etc.)
### Issue 3 — Auth web + middleware [type:feature]
Dependances : Issue 2
- [ ] Integrer `@logto/next` (sign-in, sign-out, callback)
- [ ] Middleware Next.js : proteger `/(app)/`, extraire userId
- [ ] Page de login (`/auth`) avec redirect vers Logto
- [ ] Gestion de session (cookie securise)
### Issue 4 — API REST backend [type:feature]
Dependances : Issue 2, Issue 3
- [ ] Middleware auth sur `/api/*` (session cookie + Bearer JWT avec validation signature/exp/aud/iss)
- [ ] Endpoints CRUD listes (avec soft-delete)
- [ ] Endpoints CRUD taches (avec filtres, tri, sous-taches, soft-delete)
- [ ] Endpoints CRUD tags + assignation (avec soft-delete)
- [ ] Endpoints sync (GET since + POST batch avec idempotency keys)
- [ ] Verification BOLA par entite sur le batch sync (`WHERE id = ? AND userId = ?`)
- [ ] Schemas Zod strict sur chaque endpoint (whitelist de champs, rejeter champs inconnus)
- [ ] Rate limiting granulaire (sync 10/min, creation 30/min, lecture 200/min)
- [ ] Endpoint `POST /api/ws-ticket` (nonce ephemere TTL 30s, usage unique)
- [ ] Tests API (optionnel mais recommande)
### Issue 5 — WebSocket server [type:feature]
Dependances : Issue 4
- [ ] Custom server (`server.ts`) : Next.js + `ws` sur le meme port
- [ ] Auth WebSocket par ticket ephemere (valider et invalider le nonce au handshake)
- [ ] Validation du header `Origin` contre l'allowlist
- [ ] Broadcast par userId sur chaque mutation API (type + id seulement, pas de payload)
- [ ] Re-validation de session toutes les 15 min (fermer si expiree + message `auth_expired`)
- [ ] Heartbeat 30s + reconnexion auto cote client avec backoff exponentiel
- [ ] Ne pas logger les query params sur la route `/ws`
- [ ] Mettre a jour le Dockerfile pour utiliser le custom server
### Issue 6 — Frontend web [type:feature]
Dependances : Issue 4, Issue 5
- [ ] Layout principal (sidebar listes + zone taches)
- [ ] CRUD listes (creer, renommer, supprimer, couleur/icone, reordonner)
- [ ] CRUD taches (creer, editer inline, completer, supprimer, priorite, date, notes)
- [ ] Sous-taches (affichage imbrique, creer/editer)
- [ ] Tags (creer, assigner, filtrer)
- [ ] Filtres et tri
- [ ] Recurrence (affichage, creation, auto-recurrence au complete)
- [ ] Integration WebSocket (mise a jour temps reel)
- [ ] Dark mode (light/dark/system)
- [ ] i18n FR/EN
- [ ] Responsive design
- [ ] Barre de statut sync
### Issue 7 — Sync mobile [type:feature]
Dependances : Issue 1, Issue 4
- [ ] Integrer `@logto/rn` avec adaptateur `expo-secure-store` (ne pas utiliser AsyncStorage pour les tokens)
- [ ] Bouton login dans Settings > Compte
- [ ] Access token court (15 min) + refresh token avec rotation automatique
- [ ] Intercepteur HTTP pour refresh transparent (capturer 401, rafraichir, relancer)
- [ ] Table `sync_outbox` dans SQLite (migration Drizzle) — outbox pattern
- [ ] Client sync : push outbox entries + pull changes avec idempotency keys
- [ ] Reconciliation Inbox au premier merge (remap ID hardcode → ID serveur)
- [ ] Polling periodique (2 min) + sync au lancement + sync au retour foreground
- [ ] Gestion des conflits LWW
- [ ] Ecran de premiere connexion : choix merge local ou reset serveur
- [ ] Indicateur visuel de statut sync (header)
- [ ] Mode degrade si serveur inaccessible (outbox s'accumule, sync au retour)
### Ordre d'execution
```
Issue 1 (Types partages) ──┐
├── Issue 4 (API REST) ── Issue 5 (WebSocket) ─┐
Issue 2 (Setup web + DB) ──┤ ├── Issue 6 (Frontend)
└── Issue 3 (Auth web) ─┘ │
Issue 1 ── Issue 4 ── Issue 7 (Sync mobile) │
```
## Fichiers concernes
| Fichier | Action | Raison |
|---------|--------|--------|
| `web/` | Creer | Nouveau projet Next.js complet |
| `src/shared/types.ts` | Creer | Types TypeScript partages mobile/web |
| `src/shared/colors.ts` | Creer | Palette centralisee |
| `src/shared/priority.ts` | Creer | Helpers priorite partages |
| `src/shared/recurrence.ts` | Creer | Logique recurrence partagee |
| `src/theme/colors.ts` | Modifier | Importer depuis shared/ |
| `src/lib/priority.ts` | Modifier | Importer depuis shared/ |
| `src/lib/recurrence.ts` | Modifier | Importer depuis shared/ |
| `src/db/schema.ts` | Modifier | Ajouter updatedAt aux tags |
| `src/db/schema.ts` | Modifier | Ajouter table `sync_outbox` |
| `src/db/repository/tasks.ts` | Modifier | Ecrire dans l'outbox a chaque mutation (si connecte) |
| `src/services/syncClient.ts` | Creer | Client sync (push outbox, pull changes, reconciliation Inbox) |
| `src/stores/useSettingsStore.ts` | Modifier | Ajouter syncEnabled, lastSyncAt, userId |
| `app/(tabs)/settings.tsx` | Modifier | Ajouter section Compte (login/logout/sync) |
| `app/_layout.tsx` | Modifier | Init sync polling si connecte |
| `package.json` | Modifier | Ajouter deps auth mobile (@logto/rn, expo-secure-store) |
## Criteres d'acceptation
- [ ] Un utilisateur connecte peut acceder a ses listes depuis liste.lacompagniemaximus.com
- [ ] Les operations CRUD fonctionnent pour les listes, taches, tags et sous-taches sur le web
- [ ] Les modifications sur le web apparaissent en temps reel si deux onglets sont ouverts (WebSocket)
- [ ] Les modifications sur le web apparaissent sur le mobile apres sync polling (et vice versa)
- [ ] L'app mobile continue de fonctionner sans compte (offline-first preserve)
- [ ] Au premier login mobile, l'utilisateur choisit de fusionner ou remplacer ses donnees locales
- [ ] Le site fonctionne en FR et EN, en light et dark mode
- [ ] Le site est responsive (utilisable sur mobile web)
- [ ] Les suppressions se propagent correctement (soft-delete + sync)
- [ ] Le WebSocket se reconnecte automatiquement apres une deconnexion
- [ ] Les tokens mobile sont stockes dans expo-secure-store (pas AsyncStorage)
- [ ] Le handshake WebSocket utilise un ticket ephemere (pas de cookie seul)
- [ ] Les endpoints API rejettent les champs non autorises (userId, createdAt, deletedAt)
- [ ] Le batch sync verifie l'ownership de chaque entite individuellement
- [ ] Les soft-deletes sont purges automatiquement apres 30 jours
## Edge cases et risques
| Cas | Mitigation |
|-----|------------|
| Conflit de modification simultanee (web + mobile) | Last-write-wins sur `updatedAt`. Acceptable pour usage personnel. |
| Suppression sur un appareil, modification sur l'autre | La suppression gagne (soft-delete prioritaire) |
| Serveur inaccessible depuis le mobile | Mode degrade : l'app fonctionne en local, les changements sont queued et sync au retour |
| WebSocket deconnecte | Reconnexion auto avec backoff exponentiel. Fallback : refresh manuel de la page |
| Premiere sync avec beaucoup de donnees locales | Batch l'upload en chunks de 50 entites pour eviter les timeouts |
| Token JWT expire pendant une session mobile | Refresh token avec rotation via Logto SDK. Si echec, re-login requis |
| Deux listes "Inbox" apres merge (locale + serveur) | Remap ID hardcode mobile vers ID Inbox serveur dans toutes les taches locales avant push |
| Utilisateur supprime son compte | Cascade delete de toutes les donnees sl_* du user dans PostgreSQL |
| Custom server Next.js + Coolify | S'assurer que le Dockerfile utilise `node server.ts` et non `next start`. Verifier le support WebSocket dans Caddy. |
| Changements offline perdus (crash app) | Outbox pattern : les mutations sont persistees dans `sync_outbox` SQLite avant envoi |
| Broadcast WS multi-instance | Architecture single-instance pour la v1. Si scaling necessaire, ajouter Redis pub/sub |
| VPS memoire insuffisante | Surveiller via `/api/health`. Prevoir upgrade 8 Go si migration famille-website en parallele |
| CSWSH (Cross-Site WebSocket Hijacking) | Ticket ephemere a usage unique + validation Origin + cookies SameSite=Strict |
| BOLA sur le batch sync | Verification d'ownership par entite dans chaque operation du batch |
| Replay attack sur POST /api/sync | Idempotency keys par operation + fenetre temporelle +/- 5 min |
| Token mobile sur appareil roote | Stockage dans expo-secure-store (Keychain/Keystore), jamais AsyncStorage |
| Session WS survit a l'expiration auth | Re-validation serveur toutes les 15 min, fermeture si expiree |
| Fuite de donnees via messages WS | Messages WS ne contiennent que type + id, pas le contenu des taches |
| Soft-deletes accumules indefiniment | Purge automatique apres 30 jours, full sync pour clients inactifs |
| Mass assignment via l'API | Schemas Zod strict, whitelist de champs modifiables, `userId`/`createdAt`/`deletedAt` non modifiables |
## Decisions prises
| Question | Decision | Raison |
|----------|----------|--------|
| Stack web | Next.js App Router + Drizzle + PostgreSQL | Fullstack dans un seul projet. Drizzle unifie mobile et serveur. |
| Emplacement du code | `simpl-liste/web/` dans le repo existant | Partage des types via `src/shared/`. Un seul repo a maintenir. |
| ORM serveur | Drizzle (`pg-core`) | Meme ORM que le mobile (`sqlite-core`). Un seul outil a maitriser, schemas compatibles, pas de codegen. |
| Auth | Logto (deploye) | Systeme d'auth centralise La Compagnie Maximus. |
| Sync web | WebSocket (via `ws` + custom server) | Temps reel pour le web. Le VPS a largement les ressources (4 cores, 4 Go libres). |
| Sync mobile | Polling periodique (2 min) | React Native ne maintient pas de WebSocket en background. Polling simple et fiable. |
| Conflits | Last-write-wins sur updatedAt | Simple, suffisant pour un usage personnel avec peu d'appareils. |
| Suppressions | Soft-delete (deletedAt) | Necessaire pour propager les suppressions aux autres clients via sync. |
| Compte requis | Non, optionnel | Preserver l'experience offline-first sans friction. Le login debloque la sync. |
| Migration donnees locales | Choix utilisateur (merge ou reset) | Eviter la perte de donnees et laisser le controle a l'utilisateur. |
| Identite utilisateur | `sub` Logto comme `userId` | Compte Maximus unifie cross-app. Toutes les apps actuelles et futures utilisent le meme identifiant. |
| ORM unifie | Drizzle partout (sqlite-core mobile + pg-core serveur) | Un seul ORM, schemas compatibles, types inferes directement, pas de couche de mapping. |
| Reverse proxy | Caddy (pas Traefik) | Caddy est le reverse proxy en place sur le VPS. Support natif WebSocket. |
| Outbox pattern | Table `sync_outbox` dans SQLite mobile | Les changements offline survivent aux crashes. Idempotency keys integrees. |
| Scaling WS | Single-instance, pas de Redis | Suffisant pour l'usage actuel. Redis pub/sub a ajouter si multi-instance futur. |
| Monitoring | Endpoint `/api/health` | Metriques applicatives (DB, WS, API) en complement de vps-health-api (hardware). |
## References
| Source | Pertinence |
|--------|------------|
| [Socket.IO + Next.js guide](https://socket.io/how-to/use-with-nextjs) | Pattern custom server pour WebSocket avec Next.js. On utilise `ws` directement (plus leger). |
| [Next.js WebSocket discussion #58698](https://github.com/vercel/next.js/discussions/58698) | Confirme que les route handlers App Router ne supportent pas les WS — custom server necessaire. |
| [PowerSync React Native SDK](https://docs.powersync.com/client-sdks/reference/react-native-and-expo) | Alternative evaluee pour la sync. Trop lourd pour notre cas (single user). DIY LWW + tombstones suffit. |
| [Expo local-first guide](https://docs.expo.dev/guides/local-first/) | Patterns de sync offline-first avec expo-sqlite. Valide notre approche outbox + polling. |
| [OWASP WebSocket Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html) | CSWSH, auth WS, validation Origin — base de notre strategie de ticket ephemere. |
| [OWASP API Security Top 10](https://owasp.org/www-project-api-security/) | BOLA (#1), Mass Assignment (#3) — guides les controles d'acces sur le batch sync. |
| [CSWSH Exploitation in 2025 — Include Security](https://blog.includesecurity.com/2025/04/cross-site-websocket-hijacking-exploitation-in-2025/) | Confirme que SameSite=Lax ne suffit pas si un sous-domaine est compromis. |
| [Logto Security docs](https://docs.logto.io/security) | Rotation des cles, logout centralise, stockage securise des tokens. |
| [Expo SecureStore](https://docs.expo.dev/versions/latest/sdk/securestore/) | API pour le stockage chiffre (Keychain iOS / Keystore Android) — adaptateur pour @logto/rn. |
## Estimation
7-10 sessions de travail, decoupees ainsi :
- Issues 1-2 (setup) : 1-2 sessions
- Issue 3 (auth) : 1 session
- Issue 4 (API) : 2 sessions
- Issue 5 (WebSocket) : 1 session
- Issue 6 (frontend) : 2-3 sessions
- Issue 7 (sync mobile) : 1-2 sessions

View file

@ -4,7 +4,6 @@ import { syncOutbox, lists, tasks, tags, taskTags } from '@/src/db/schema';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import { getAccessToken } from '@/src/lib/authToken';
import { randomUUID } from '@/src/lib/uuid';
import { syncWidgetData } from '@/src/services/widgetSync';
const SYNC_API_BASE = 'https://liste.lacompagniemaximus.com';
const INBOX_ID = '00000000-0000-0000-0000-000000000001';
@ -98,8 +97,6 @@ export async function pushChanges(): Promise<void> {
.set({ syncedAt: now })
.where(eq(syncOutbox.id, entry.id));
}
// Refresh widget after a successful push to reflect the synced state
syncWidgetData().catch(() => {});
}
}
@ -121,11 +118,9 @@ export async function pullChanges(since: string): Promise<void> {
const data: SyncPullResponse = await res.json();
let appliedChanges = 0;
for (const change of data.changes) {
try {
await applyChange(change);
appliedChanges++;
} catch (err) {
console.warn(`[sync] failed to apply change for ${change.entity_type}/${change.entity_id}:`, err);
}
@ -135,11 +130,6 @@ export async function pullChanges(since: string): Promise<void> {
if (data.sync_token) {
useSettingsStore.getState().setLastSyncAt(data.sync_token);
}
// Refresh widget once after applying all remote changes
if (appliedChanges > 0) {
syncWidgetData().catch(() => {});
}
} catch (err) {
console.warn('[sync] pull error:', err);
}

View file

@ -4,21 +4,9 @@ import { TaskListWidget } from './TaskListWidget';
import { getWidgetState, setWidgetState, WIDGET_NAMES, type WidgetTask } from '../services/widgetSync';
import { isValidUUID } from '../lib/validation';
const EXPAND_DEBOUNCE_MS = 600;
const EXPAND_DEBOUNCE_MS = 2000;
const lastExpandTimes = new Map<string, number>();
// Dev-only timing helper. Output goes to logcat:
// adb logcat -s ReactNativeJS | grep '\[widget\]'
async function timed<T>(label: string, fn: () => Promise<T> | T): Promise<T> {
if (!__DEV__) return await fn();
const start = Date.now();
try {
return await fn();
} finally {
console.log(`[widget] ${label}: ${Date.now() - start}ms`);
}
}
function renderWithState(
renderWidget: WidgetTaskHandlerProps['renderWidget'],
widgetInfo: WidgetTaskHandlerProps['widgetInfo'],
@ -56,30 +44,17 @@ async function forceWidgetRefresh(
}
}
// Best-effort persist: failure leaves AsyncStorage stale, but the next
// handler call's getWidgetState() returns the prior state and re-renders
// from it, so the UI self-heals on the next interaction.
async function persistState(state: Awaited<ReturnType<typeof getWidgetState>>): Promise<void> {
try {
await setWidgetState(state);
} catch {
if (__DEV__) console.log('[widget] setWidgetState failed (state will resync on next sync push)');
}
}
export async function widgetTaskHandler(
props: WidgetTaskHandlerProps
): Promise<void> {
const { widgetAction, widgetInfo, renderWidget } = props;
const handlerStart = __DEV__ ? Date.now() : 0;
switch (widgetAction) {
case 'WIDGET_ADDED':
case 'WIDGET_UPDATE':
case 'WIDGET_RESIZED': {
const state = await timed(`${widgetAction} getState`, getWidgetState);
const state = await getWidgetState();
renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds);
if (__DEV__) console.log(`[widget] ${widgetAction} total: ${Date.now() - handlerStart}ms`);
break;
}
@ -91,36 +66,31 @@ export async function widgetTaskHandler(
const taskId = props.clickActionData?.taskId;
if (!isValidUUID(taskId)) break;
const state = await timed('TOGGLE_COMPLETE getState', getWidgetState);
const state = await getWidgetState();
state.tasks = state.tasks.filter((t) => t.id !== taskId);
await setWidgetState(state);
// Render first so the user sees the row disappear immediately,
// then persist + run the DB write.
renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds);
await timed('TOGGLE_COMPLETE setState', () => persistState(state));
try {
const { toggleComplete } = await import('../db/repository/tasks');
await timed('TOGGLE_COMPLETE db', () => toggleComplete(taskId));
await toggleComplete(taskId);
} catch {
// DB might not be available in headless mode
}
if (__DEV__) console.log(`[widget] TOGGLE_COMPLETE total: ${Date.now() - handlerStart}ms`);
}
if (props.clickAction === 'TOGGLE_EXPAND') {
const taskId = props.clickActionData?.taskId as string | undefined;
if (!taskId) break;
// Anti-double-tap. Short enough to not feel laggy when the user
// genuinely wants to expand-then-collapse.
// Debounce: ignore rapid double-taps on the same task
const now = Date.now();
const lastTime = lastExpandTimes.get(taskId) ?? 0;
if (now - lastTime < EXPAND_DEBOUNCE_MS) break;
lastExpandTimes.set(taskId, now);
const state = await timed('TOGGLE_EXPAND getState', getWidgetState);
const state = await getWidgetState();
const expandedSet = new Set(state.expandedTaskIds);
if (expandedSet.has(taskId)) {
@ -129,11 +99,9 @@ export async function widgetTaskHandler(
expandedSet.add(taskId);
}
state.expandedTaskIds = [...expandedSet];
await setWidgetState(state);
renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds);
await timed('TOGGLE_EXPAND setState', () => persistState(state));
if (__DEV__) console.log(`[widget] TOGGLE_EXPAND total: ${Date.now() - handlerStart}ms`);
}
if (props.clickAction === 'TOGGLE_SUBTASK') {
@ -141,8 +109,9 @@ export async function widgetTaskHandler(
const parentId = props.clickActionData?.parentId as string | undefined;
if (!isValidUUID(subtaskId) || !parentId) break;
const state = await timed('TOGGLE_SUBTASK getState', getWidgetState);
const state = await getWidgetState();
// Update subtask state in cached data
const parent = state.tasks.find((t) => t.id === parentId);
if (parent) {
const sub = parent.subtasks?.find((s) => s.id === subtaskId);
@ -151,24 +120,16 @@ export async function widgetTaskHandler(
parent.subtaskDoneCount = (parent.subtasks ?? []).filter((s) => s.completed).length;
}
}
await setWidgetState(state);
// forceWidgetRefresh fans out to all 3 widget sizes (state changed
// affects every widget on the home screen). Run before persist for
// immediate visual feedback; the data passed in is the in-memory
// mutated state, not re-read from AsyncStorage.
await timed('TOGGLE_SUBTASK render', () =>
forceWidgetRefresh(state.tasks, state.isDark, state.expandedTaskIds)
);
await timed('TOGGLE_SUBTASK setState', () => persistState(state));
await forceWidgetRefresh(state.tasks, state.isDark, state.expandedTaskIds);
try {
const { toggleComplete } = await import('../db/repository/tasks');
await timed('TOGGLE_SUBTASK db', () => toggleComplete(subtaskId));
await toggleComplete(subtaskId);
} catch {
// DB might not be available in headless mode
}
if (__DEV__) console.log(`[widget] TOGGLE_SUBTASK total: ${Date.now() - handlerStart}ms`);
}
break;
}

View file

@ -1,85 +0,0 @@
'use strict';
// Non-regression smoke tests for npm overrides + deps integrity.
// Runs on plain node (no jest, no Expo runtime). Catches the obvious
// breakage paths after `npm install` rewrites lock for an `overrides` bump.
//
// node tests/smoke.test.cjs
//
// Exit 0 if all checks pass, 1 if any fails.
const assert = require('node:assert').strict;
let failed = 0;
function check(name, fn) {
try {
fn();
console.log(`OK ${name}`);
} catch (e) {
failed++;
console.error(`FAIL ${name}: ${e.message}`);
}
}
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
check('package.json is valid JSON with name + deps', () => {
const pkg = require('../package.json');
assert.ok(pkg.name, 'package.json missing name');
assert.ok(pkg.dependencies, 'package.json missing dependencies');
});
check('uuid v4 generates well-formed UUID', () => {
const { v4 } = require('uuid');
for (let i = 0; i < 5; i++) {
assert.match(v4(), UUID_RE);
}
});
// The uuid <14.0.0 advisory (GHSA-w5hq-g745-h8pq) is specifically about
// missing buffer bounds checks in v3/v5/v6. After bumping to v14, these
// must still produce valid UUIDs from a name + namespace.
check('uuid v3 with namespace produces valid UUID', () => {
const { v3 } = require('uuid');
const id = v3('test-name', NAMESPACE);
assert.match(id, UUID_RE);
});
check('uuid v5 with namespace produces valid UUID', () => {
const { v5 } = require('uuid');
const id = v5('test-name', NAMESPACE);
assert.match(id, UUID_RE);
});
// Buffer-arg path is the actual vuln site — must not throw and must
// fill the buffer with the UUID bytes.
check('uuid v3 with buffer arg fills buffer (vuln site)', () => {
const { v3 } = require('uuid');
const buf = Buffer.alloc(16);
v3('test-name', NAMESPACE, buf);
// After the call, buf must have at least one non-zero byte.
assert.ok(
buf.some((b) => b !== 0),
'buffer was not filled by uuid v3'
);
});
check('uuid v5 with buffer arg fills buffer (vuln site)', () => {
const { v5 } = require('uuid');
const buf = Buffer.alloc(16);
v5('test-name', NAMESPACE, buf);
assert.ok(
buf.some((b) => b !== 0),
'buffer was not filled by uuid v5'
);
});
if (failed === 0) {
console.log('\nsmoke OK');
process.exit(0);
} else {
console.error(`\nsmoke FAIL (${failed} failure${failed > 1 ? 's' : ''})`);
process.exit(1);
}

View file

@ -15,7 +15,7 @@ COPY . .
RUN npm run build
# Bundle custom server + ws into a single JS file
RUN npx esbuild server.ts --bundle --platform=node --target=node22 --outfile=dist-server/server.js \
--external:next --external:.next --external:pg --external:pg-native --external:drizzle-orm
--external:next --external:.next
# Production
FROM base AS runner
@ -31,7 +31,6 @@ COPY --from=builder /app/package.json ./
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/dist-server/server.js ./server.js
COPY --from=builder --chown=nextjs:nodejs /app/src/db/migrations ./src/db/migrations
USER nextjs
EXPOSE 3000

View file

@ -1,38 +1,15 @@
import { createServer } from 'http';
import next from 'next';
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { setupWebSocket } from './src/lib/ws';
const dev = process.env.NODE_ENV !== 'production';
const hostname = process.env.HOSTNAME || '0.0.0.0';
const port = parseInt(process.env.PORT || '3000', 10);
async function runMigrations() {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const db = drizzle(pool);
try {
await migrate(db, { migrationsFolder: './src/db/migrations' });
console.log('> Migrations applied');
} finally {
await pool.end();
}
}
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
(async () => {
try {
await runMigrations();
} catch (err) {
console.error('> Migration error:', err);
process.exit(1);
}
await app.prepare();
app.prepare().then(() => {
const server = createServer((req, res) => {
// Don't log query params on /ws route (ticket security)
handle(req, res);
@ -44,4 +21,4 @@ const handle = app.getRequestHandler();
console.log(`> Ready on http://${hostname}:${port}`);
console.log(`> WebSocket server on ws://${hostname}:${port}/ws`);
});
})();
});

View file

@ -1,45 +0,0 @@
-- Cleanup duplicate inboxes per user (#60)
-- For each user with more than one active inbox, keep the oldest one
-- (lowest created_at), reassign all tasks to it, and soft-delete the duplicates.
WITH ranked_inboxes AS (
SELECT
id,
user_id,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS rn
FROM sl_lists
WHERE is_inbox = true
AND deleted_at IS NULL
),
canonical AS (
SELECT user_id, id AS canonical_id
FROM ranked_inboxes
WHERE rn = 1
),
duplicates AS (
SELECT r.id AS duplicate_id, c.canonical_id, r.user_id
FROM ranked_inboxes r
JOIN canonical c ON c.user_id = r.user_id
WHERE r.rn > 1
)
-- Reassign tasks from duplicate inboxes to the canonical one
UPDATE sl_tasks
SET list_id = d.canonical_id, updated_at = NOW()
FROM duplicates d
WHERE sl_tasks.list_id = d.duplicate_id
AND sl_tasks.user_id = d.user_id;
--> statement-breakpoint
-- Soft-delete the duplicate inboxes
WITH ranked_inboxes AS (
SELECT
id,
user_id,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS rn
FROM sl_lists
WHERE is_inbox = true
AND deleted_at IS NULL
)
UPDATE sl_lists
SET deleted_at = NOW(), updated_at = NOW()
WHERE id IN (SELECT id FROM ranked_inboxes WHERE rn > 1);

View file

@ -15,13 +15,6 @@
"when": 1775567900000,
"tag": "0001_change_user_id_to_text",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1775649600000,
"tag": "0002_cleanup_duplicate_inboxes",
"breakpoints": true
}
]
}