fix agent

This commit is contained in:
m
2026-09-12 18:57:16 +02:00
parent 608ef8757e
commit b7eb291c84
+49 -45
View File
@@ -2,89 +2,93 @@
## Project Status ## Project Status
Project initialized — `mobile/` (React Native / Expo) and `backend/` (Go) have scaffolding in place. The SQLite layer (schema v4, migrations, repositories) is implemented and covered by tests. `backend/` (Go) et `mobile-kotlin/` (Kotlin/Android) ont du scaffolding en place. Le client mobile est **local-first** : il reflète le SAF dans une base Room (v5, migrations, DAO) et n'utilise le serveur que pour s'enregistrer, se connecter et lister fichiers/dossiers. Pas de poussée cloud (outbox), pas d'upload, pas d'OCR ni de recherche serveur côté client en V1.
## Architecture ## Architecture
- **Backend**: Go, Gin HTTP framework, PostgreSQL, Tesseract OCR (system call) - **Backend**: Go, Gin HTTP framework, PostgreSQL, Tesseract OCR (system call)
- **Frontend**: React Native (Expo SDK 57), expo-router, expo-sqlite, expo-file-system (SAF) - **Frontend**: Kotlin/Android — Jetpack Compose, Room (SQLite), Hilt (DI), Retrofit + Moshi + OkHttp, SAF (DocumentsContract)
## Key Commands ## Key Commands
```bash ```bash
# Backend # Backend
mise up_backend # docker compose up postgres + go run cmd/server/main.go
cd backend && go run cmd/server/main.go cd backend && go run cmd/server/main.go
# Backend tests (tests repo/handlers via dbtest)
cd backend && go test ./...
# Frontend (build + install app Android debug)
mise up_mobile [device|emulator] # défaut: emulator
cd mobile-kotlin && ./gradlew :app:assembleDebug
# PostgreSQL (via docker-compose) # PostgreSQL (via docker-compose)
docker compose up postgres -d docker compose up postgres -d
# Frontend
cd mobile && npx expo start
# Typecheck frontend
cd mobile && npx tsc --noEmit
# SQLite layer tests (migrations + repositories)
cd mobile && npm run test:db
``` ```
Il n'y a **pas** de tests mobiles (pas de dossier `src/test` ni `src/androidTest`).
## Backend Structure ## Backend Structure
- Entry point: `backend/cmd/server/main.go` (wiring gin + config + routes) - Entry point: `backend/cmd/server/main.go` (wiring gin + config + routes)
- `config/` — env (`godotenv`, optionnel) + defaults: `PORT`, `DATABASE_URL`, `UPLOAD_DIR`, `MAX_FILE_SIZE_MB`, `OCR_LANG`, secret paseto - `config/` — env (`godotenv`, optionnel) + defaults: `PORT`, `DATABASE_URL`, `UPLOAD_DIR`, `MAX_FILE_SIZE_MB`, `OCR_LANG`, `AUTH_SECRET` (secret des tokens paseto), `ADMIN_USERNAME`/`ADMIN_PASSWORD` (bootstrap du premier admin)
- `models/` — domain entities (users, devices, documents/resources, clients) - `models/` — domain entities (users, devices, documents/resources, clients)
- `service/` — business logic (permissions, upload, create folder, move, **sync outbox + snapshot**) - `service/` — business logic (permissions, upload, create folder, move, **sync outbox + snapshot**, bootstrap admin)
- `handlers/` — HTTP handlers (health, devices register + paseto, files CRUD/upload/search, folders, **sync/ops + sync/permissions, ocr/jobs** — réels) - `handlers/` — HTTP handlers (health, devices register, **auth/login**, **users resolve + change password**, files CRUD/upload/search, folders, **sync/ops + sync/permissions, ocr/jobs** — réels) ; middleware `RequireAuth`
- `repository/` — Postgres persistence réelle (`repository.Resources` : insert/list/get/soft-delete scoping `owner_id`, **search, move, rename, root-name unique index**, `repository.Devices.Upsert`, `repository.Operations` : trace outbox idempotente `(device_id, operation_id)`, `ListOwned` pour le snapshot, `repository.OcrJobs` : jobs queued→processing→done/failed) ; IDs sont TEXT 32-hex, `NewID()` = `crypto/rand` 16 octets hex (jamais UUID conversion, cf. `docs/api-v1.md`) - `repository/` — Postgres persistence (`repository.Resources` : insert/list/get/soft-delete scoping `owner_id`, search, move, rename, root-name unique index, `ListOwned` pour le snapshot ; `repository.Devices` : Upsert, Exists, MarkUser ; `repository.Operations` : trace outbox idempotente `(device_id, operation_id)` ; `repository.OcrJobs` : jobs queued→processing→done/failed ; `repository.Users` : GetByUsernameNormalized, ResolveExact, UpdatePassword) ; IDs sont TEXT 32-hex, `NewID()` = `crypto/rand` 16 octets hex (jamais UUID conversion, cf. `docs/api-v1.md`)
- `db/` — package migrations (`golang-migrate/v4`, embarquées via `embed` dans `db/migrations/*.sql`) : `db.MigrateDatabase(url)` au boot du serveur ; test harness `db/migrations_test.go` (up → assertions schéma → down, `TEST_DATABASE_URL`, skip si PG indisponible) ; `dbtest/` — helper cross-package pour les tests repo/handlers (crée la DB test si absente, reset schema, migrate ; skip si PG down) - `db/` — package migrations (`golang-migrate/v4`, embarquées via `embed` dans `db/migrations/*.sql`, 000001→000007) : `db.MigrateDatabase(url)` au boot du serveur ; test harness `db/migrations_test.go` (up → assertions schéma → down, `TEST_DATABASE_URL`, skip si PG indisponible) ; `dbtest/` — helper cross-package pour les tests repo/handlers (crée la DB test si absente, reset schema, migrate ; skip si PG down)
- `ocr/` — OCR engine behind an interface (Tesseract system call, `OCR_LANG` défaut `fra+eng`) - `ocr/` — OCR engine behind an interface (Tesseract system call, `OCR_LANG` défaut `fra+eng`)
- Response helpers: `pkg/api/response.go` - `pkg/api/` — response helpers (`response.go`) ; `pkg/auth/` — tokens **paseto v4-local** (subject = `user_id`, claim = `device_id`, TTL 7j, voir `docs/api-v1.md`) ; `pkg/passwd/` — hashing/vérification bcrypt (timing-equal)
- File uploads stored in `backend/uploads/` - File uploads stored in `backend/uploads/`
- Standard JSON response envelope: `{ "data": ..., "meta": { "page": ..., "total": ... } }` - Standard JSON response envelope: `{ "data": ..., "meta": { "page": ..., "total": ... } }`
- Error format: `{ "error": { "code": "...", "message": "..." } }` - Error format: `{ "error": { "code": "...", "message": "..." } }`
- Route list is a tracked contract (`cmd/server/router_test.go` mirrors `mobile/api/client.ts`) - Route list is a tracked contract (`cmd/server/router_test.go` mirrors the mobile client)
## Frontend Structure ## Frontend Structure (mobile-kotlin)
- Entry point: `mobile/App.tsx` (expo-router layout + Auth context) - Entry point: `app/src/main/java/com/vaultdrop/mobile/VaultDropApplication.kt` + `MainActivity.kt` (Hilt) ; navigation Compose dans `ui/navigation/` (`NavGraph.kt`, `VaultDropApp.kt`)
- Data layer — `mobile/services/`: - Data layer — `data/`:
- `safDirectory.ts` + `safDirectory.types.ts`: physical access via `expo-file-system` (pick/list/create, Documents/SAF uris) - `data/local/` — Room SQLite (DB `dot.db`, **version 5**, `Migrations.kt` : tables `folders`, `files`, `user_preferences`) : entités Folder/File/UserPreference, DAO, tri (`FileOrdering`)
- `db/` — SQLite persistence, see `mobile/AGENTS.md` for the full contract (schema, migrations, repositories, tests) - `data/remote/` — Retrofit/Moshi : `ApiService.kt` + `dto/Dtos.kt` = **contrat HTTP** (`{ data, meta }`, erreurs `{ error: { code, message } }`) ; `ApiClient.kt` normalise les réponses ; interceptors OkHttp (`AuthInterceptor`, `ServerUrlInterceptor`)
- `localStorage.ts` — thin re-export of `services/db` (legacy alias) - `data/repository/``FolderRepository`, `FileRepository`, `AuthRepository`
- `features/syncDevice.ts` — device sync orchestration (two-pass SAF walk, single transaction per root, `exists = 0` reconciliation) - `features/` — logique descendue côté client :
- `features/syncOutbox.ts` — pulls `pending_operations` to `POST /sync/ops` (resume-at-`applied` index, transient retries never bump `attempts`) and `GET /sync/permissions` delta snapshot; both no-ops without an auth token - `sync/DeviceSync.kt` + `SafScanner.kt` — sync device↔SAF : **two-pass walk** (listing hors transaction puis upserts Room dans une seule transaction), réconciliation `exists = 0` (jamais de suppression), single-flight via `Mutex`
- `context/AuthContext.tsx` — session context: exposes `deviceUserId` (bootstrapped from `getDeviceUserId()`) and starts the background `syncDevice` loop - `saf/``SafUris`, `SafFolderCreator`, `FileMover` (relocalisation SAF `DocumentsContract.moveDocument`, repli métadonnée seule si échec ; pas de poussée serveur)
- `app/` — expo-router screens: `index.tsx` (dossiers racines + ajout SAF), `folder/[id].tsx` (sous-dossiers + fichiers) - `sync/SyncViewModel.kt` — état du sync exposé à l'UI
- `api/`REST client (`client.ts` fetch wrapper + `types.ts` = contrat d'API : enveloppe `{ data, meta }`, erreurs `{ error: { code, message } }`) - `auth/`session (login user + token paseto) : `SessionManager`, `SecureTokenStore`, `TokenProvider`
- `hooks/`TanStack Query hooks: `useFiles`, `useSearch`, `useUpload` (+ OCR jobs) - `ui/`écrans Compose : `folderlist`, `folderdetail`, `document` (contenu PDF/image/texte + placeholder cloud-only), `search` (**recherche locale** via Room, sans endpoint serveur), `settings` (URL serveur + thème), `pdfbuilder` (multi-select → génération PDF), `auth` (login, mode local), `components`, `theme`
- No business logic on the client — heavy processing stays server-side - `domain/` — stores de préférences: `DeviceIdentity`, `ActiveUserStore`, `LocalModeStore`, `ServerConfigStore`, `ThemePreferenceStore`, `GenerateId` (identifiants 32-hex)
- API base URL via `EXPO_PUBLIC_API_BASE_URL` (défaut `http://localhost:8080/api/v1`) - `di/` — modules Hilt (`AppModule`, `DatabaseModule`, `NetworkModule`)
- Base URL serveur **configurée à l'exécution** dans les Réglages (`ServerConfigStore`, persistée en `user_preferences`), défaut `http://10.0.2.2:8080/api/v1` (pas de variable d'env)
- Client **local-first** : aucune poussée cloud (pas d'outbox, pas de `sync/ops`), juste `GET /files/folders`, `GET /files`, `POST /devices`, `POST /auth/login` ; téléchargement/upload hors scope
## Data Conventions ## Data Conventions
- Canonical identity for folders/files/shares/share_links is `resource_id`: opaque `lower(hex(randomblob(16)))`, generated locally, never reused. The physical `uri` is nullable (NULL = cloud-only) and is the reconciliation key for the SAF walk. - Canonical identity for folders/files is `resource_id`: opaque 32-hex, generated locally (`GenerateId`), never reused. The physical `uri` (SAF) is nullable (NULL = cloud-only) and is the reconciliation key for the SAF walk (unique index, NULLs distincts).
- `owner_id` is NOT NULL on every folder/file row, seeded from the device's `device_user_id`. - `owner_id` is **nullable** in the Room schema; it is set at runtime from `DeviceIdentity.getOrCreate()` during the SAF walk.
- Folder/file `sync_status` is a **placement** state: `local` | `cloud` | `local-cloud` (transitions via `transitionSyncStatus`). It is not a push progress marker. - Folder/file `sync_status` is a **placement** state: `local` | `cloud` | `local-cloud`. It is not a push progress marker.
- Shares/share_links carry no `sync_status`; their `pushStatus` (pending/synced/failed) is derived from the `pending_operations` outbox. - Client storage: `user_preferences` (clé/valeur) pour l'identité device, le compte actif, l'URL serveur, le thème, le mode local.
- Decisions are made **offline** from a cached `resource_permissions` snapshot pushed by the server; the server remains the source of truth. `canAccess` enforces ranking (viewer < commenter < editor < owner), `inherit`, `expires_at`, and a 24h stale-cache read-only downgrade. - Pas d'outbox ni de cache de permissions côté client en V1 : le serveur reste la source de vérité, le client ne pousse rien (hors scope).
- `password_hash` and download counters are **server-side only**; the client only stores the `has_password` boolean and a counter mirror.
## API Contract (V1) ## API Contract (V1)
- **The mobile client is the contract**: endpoint shapes in `mobile/api/types.ts` + `mobile/api/client.ts` are authoritative and must match exactly; the server does not renegotiate them. Consolidated spec: `docs/api-v1.md`. - **Le client mobile est le contrat**: les formes d'endpoints dans `mobile-kotlin/.../data/remote/ApiService.kt` + `dto/Dtos.kt` sont autoritatives et doivent matcher exactement ; le serveur ne renégocie pas. Spec consolidée: `docs/api-v1.md`.
- **Identity**: device-first. The device registers (`POST /devices`) and authenticates with a paseto bearer token; no user accounts in V1 (`users` table exists but `devices.user_id` stays NULL). - **Identity (user-first, V1 finale)**: le device s'enregistre (`POST /devices`, `{ "deviceId" }` seul, sans token), puis `POST /auth/login` (`username` + `password` + `device_id`) émet le seul token paseto **v4-local** — subject = `user_id` (autorise, scoping de toutes les ressources), claim `device_id` (porté, non autorisant seul), **TTL 7j sans refresh**. À expiration, le client re-logine.
- **Identifiers**: `resource_id` / `device_user_id` / share-link `token` are opaque **lowercase 32-hex** TEXT (`^[0-9a-f]{32}$`, CHECK-enforced), stored as-is server-side (no UUID conversion). The mobile always generates `lower(hex(randomblob(16)))`. - **Bootstrap**: au premier démarrage, si `users` est vide, `ADMIN_USERNAME`/`ADMIN_PASSWORD` (env) créent le premier admin ; absents → le serveur refuse de démarrer. L'env n'écrase jamais un compte existant. Usernames résolus sur `username_normalized` (lowercase + trim), `GET /users/resolve` exact uniquement (pas d'énumération).
- **Outbox idempotence + ordering**: client pushes batches of `pending_operations`; each operation carries `operation_id` (= client `pending_operations.id`), server enforces uniqueness per device. Batches are applied **sequentially**; the server stops at the first non-idempotent failure and returns the index reached so the client resumes there (outbox retry/backoff can reorder). - **Identifiers**: `resource_id` / `device_user_id` / `user_id` / share-link `token` sont des **lowercase 32-hex** TEXT (`^[0-9a-f]{32}$`, CHECK-enforced), stockés tels quels côté serveur (pas de conversion UUID). Le mobile génère toujours 32-hex.
- **Permissions snapshot**: the server pushes `resource_permissions` snapshots (`effective_access` ranking viewer < commenter < editor < owner, `inherit`, `expires_at`, TTL 24h → read-only downgrade) that the offline `canAccess` consumes. - **Outbox idempotence + ordering**: le serveur applique les batchs de `pending_operations` **séquentiellement**, s'arrête à la première erreur non-idempotente et retourne l'index atteint. (Endpoint `POST /sync/ops` existant côté serveur ; pas encore consommé par le client Kotlin.)
- **Permissions snapshot**: le serveur pousse des snapshots `resource_permissions` (`effective_access` ranking viewer < commenter < editor < owner, `inherit`, `expires_at`, TTL 24h → read-only downgrade). (Endpoint existant côté serveur ; pas encore consommé par le client Kotlin.)
## Non-Goals (V1) ## Non-Goals (V1)
- Plugin system - Plugin system
- On-device OCR - On-device OCR
- Full multi-tenant federation / public discovery - Full multi-tenant federation / public discovery
- Multi-writer sync conflicts (single-owner device identity; device-local `device_user_id`) - Multi-writer sync conflicts (single-owner device identity)
- Client→cloud push (outbox, permissions, OCR jobs, upload) côté mobile Kotlin
## References ## References
- `docs/api-v1.md`**contrat API V1** (autoritatif, consolidé depuis `mobile/api/types.ts`) - `docs/api-v1.md`**contrat API V1** (autoritatif, consolidé depuis le client `data/remote/`)
- `V2.md` — modèle cible Postgres/ReBAC (identifiants en TEXT 32-hex, cf. `docs/api-v1.md`) - `V2.md` — modèle cible Postgres/ReBAC (identifiants en TEXT 32-hex, cf. `docs/api-v1.md`)