From e7f304da0a6593c97b671c1fc84077f81aa6aa35 Mon Sep 17 00:00:00 2001 From: m Date: Thu, 10 Sep 2026 07:10:19 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20contrat=20api-v1=20consolid=C3=A9=20+?= =?UTF-8?q?=20conventions=20(mobile=20=3D=20source=20de=20v=C3=A9rit=C3=A9?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 24 +++++++-- README.md | 2 + V1.md | 2 + V2.md | 2 + docker-compose.yml | 30 ++++------- docs/api-v1.md | 129 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 165 insertions(+), 24 deletions(-) create mode 100644 docs/api-v1.md diff --git a/AGENTS.md b/AGENTS.md index 29b6384..8dc58ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,14 +30,18 @@ cd mobile && npm run test:db ## Backend Structure -- Entry point: `backend/cmd/server/main.go` -- Internal packages: `handlers/`, `models/`, `repository/`, `service/`, `ocr/` +- 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 +- `models/` — domain entities (users, devices, documents/resources, clients) +- `service/` — business logic (permissions, upload, create folder, move) +- `handlers/` — HTTP handlers (bind the routes; currently 501 not-implemented stubs) +- `repository/` — Postgres persistence (`golang-migrate` + `lib/pq`); IDs are TEXT 32-hex (never UUID conversion, cf. `docs/api-v1.md`) +- `ocr/` — OCR engine behind an interface (Tesseract system call, `OCR_LANG` défaut `fra+eng`) - Response helpers: `pkg/api/response.go` - File uploads stored in `backend/uploads/` - Standard JSON response envelope: `{ "data": ..., "meta": { "page": ..., "total": ... } }` - Error format: `{ "error": { "code": "...", "message": "..." } }` -- OCR language: `fra+eng` -- Handlers are stub implementations (return not-implemented errors) +- Route list is a tracked contract (`cmd/server/router_test.go` mirrors `mobile/api/client.ts`) ## Frontend Structure @@ -63,6 +67,14 @@ cd mobile && npm run test:db - 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. - `password_hash` and download counters are **server-side only**; the client only stores the `has_password` boolean and a counter mirror. +## 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`. +- **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). +- **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)))`. +- **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). +- **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. + ## Non-Goals (V1) - Plugin system @@ -72,4 +84,6 @@ cd mobile && npm run test:db ## References -- `README.md` — full spec, API endpoints, data flow, folder structure, iteration roadmap \ No newline at end of file +- `docs/api-v1.md` — **contrat API V1** (autoritatif, consolidé depuis `mobile/api/types.ts`) +- `README.md` / `V1.md` — specs **obsolètes** (bannières en tête de fichier) +- `V2.md` — modèle cible Postgres/ReBAC (identifiants en TEXT 32-hex, cf. `docs/api-v1.md`) \ No newline at end of file diff --git a/README.md b/README.md index 4247b52..c2349a0 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # VaultDrop — Application de Gestion de Fichiers V1 +> ⚠️ **Obsolète** — cette spec décrit une V1 single-user (SQLite). Référentiel actuel : `docs/api-v1.md` (contrat API) et `V2.md` (modèle cible Postgres/ReBAC). + ## Vision Application mobile tout-en-un permettant de centraliser, organiser et retrouver ses documents. Upload depuis l'appareil, scan caméra avec OCR, tagging et recherche rapide. Le back-end Go assure le traitement asynchrone (OCR, indexing) et la persistence. Le front React Native reste léger : il affiche, interagit et met en cache. diff --git a/V1.md b/V1.md index 6056251..59590bd 100644 --- a/V1.md +++ b/V1.md @@ -1,5 +1,7 @@ # VaultDrop Backend — Spécification V1 (État Existant) +> ⚠️ **Obsolète** — référentiel actuel : `docs/api-v1.md` (contrat API) et `V2.md` (modèle cible). L'OCR est décidé en **Tesseract (appel système)**, pas de microservice Python. + ## 1. Vue d'ensemble Application de gestion de documents : upload, scan OCR, tagging, recherche, organisation en dossiers. Backend Go avec API REST, base PostgreSQL, microservice OCR Python séparé. diff --git a/V2.md b/V2.md index 6ecab91..630048b 100644 --- a/V2.md +++ b/V2.md @@ -1,6 +1,8 @@ Architecture Offline-First Multi-Device pour Application Mobile Version : 1.0 +> ⚠️ **Écart acté** : les identifiants (`resources.id`, devices, FK) sont stockés en **TEXT 32-hex minuscule** (`^[0-9a-f]{32}$`), pas en `uuid` Postgres — cf. `docs/api-v1.md` (le mobile produit du 32-hex, zéro transformation à la frontière). + Date : 28 juillet 2026 Statut : Proposition technique diff --git a/docker-compose.yml b/docker-compose.yml index 58bab77..82ec649 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,19 +1,14 @@ services: - paddleocr: - build: - context: ./backend/ocr-server - dockerfile: Dockerfile - ports: - - "9090:8080" - environment: - - OCR_LANG=fr - restart: unless-stopped - healthcheck: - test: ["CMD", "curl", "-sf", "http://localhost:8080/health"] - interval: 30s - timeout: 10s - retries: 5 - start_period: 120s + # paddleocr envisagé, non retenu — OCR via Tesseract (appel système), cf. docs/api-v1.md + # paddleocr: + # build: + # context: ./backend/ocr-server + # dockerfile: Dockerfile + # ports: + # - "9090:8080" + # environment: + # - OCR_LANG=fr + # restart: unless-stopped postgres: image: postgres:17-alpine @@ -41,15 +36,12 @@ services: # environment: # - PORT=8080 # - DATABASE_URL=postgres://vaultdrop:vaultdrop@postgres:5432/vaultdrop?sslmode=disable - # - OCR_ENDPOINT=http://paddleocr:8080 # volumes: # - ./backend/uploads:/app/uploads # depends_on: - # paddleocr: - # condition: service_healthy # postgres: # condition: service_healthy # restart: unless-stopped volumes: - postgres-data: + postgres-data: \ No newline at end of file diff --git a/docs/api-v1.md b/docs/api-v1.md new file mode 100644 index 0000000..4f57da0 --- /dev/null +++ b/docs/api-v1.md @@ -0,0 +1,129 @@ +# VaultDrop — Contrat API V1 (autoritatif) + +Status : **autoritatif**. Le client mobile est la source de vérité : `mobile/api/types.ts` + `mobile/api/client.ts` sont implémentés et testés ; le serveur Go doit les matcher exactement (méthode + path + enveloppe), il ne re-négocie pas. Document consolidé à partir de ces deux fichiers — toute divergence de ce doc doit être portée dans le client d'abord. + +Références : `V2.md` (modèle cible), `mobile/services/db/` (conventions sync), `README.md`/`V1.md` (**obsolètes**). + +--- + +## 1. Base et transport + +- Base URL serveur : schéma + host configurés côté client via `EXPO_PUBLIC_API_BASE_URL` (défaut `http://localhost:8080/api/v1`). +- JSON partout, sauf `POST /files/upload` (multipart). +- Enveloppe succès : `{ "data": T, "meta"?: { "page": int, "pageSize": int, "total": int } }` (`meta` présent sur les listes paginées). +- Erreur : `{ "error": { "code": string, "message": string } }` + statut HTTP adéquat. +- Côté client, toute réponse non-`2xx` est normalisée en `ApiError` : `code` du body si présent, sinon `HTTP_` ; échec réseau → `NETWORK_ERROR`. + +## 2. Identité et identifiants (invariants) + +- **Device-first** : le device s'enregistre (`POST /devices`) et reçoit un token **paseto** qu'il stocke localement. Requêtes suivantes : `Authorization: Bearer ` ; le serveur en résout le `device_id`. V1 : pas de comptes utilisateurs (`users.user_id` reste NULL sur `devices`). +- **Identifiants** : `resource_id`, `device_user_id`, `token` de share-link = **TEXT opaque 32-hex minuscule**, `^[0-9a-f]{32}$`. Le mobile génère toujours `lower(hex(randomblob(16)))` ; le serveur stocke **tel quel**, sans conversion UUID (cf. note V2.md). Contrainte serveur : `CHECK (col ~ '^[0-9a-f]{32}$')` sur toutes les colonnes id + FK. +- Horodatages échangés en **millisecondes epoch** (le mobile utilise `Date.now()`). + +## 3. Endpoints + +| Méthode | Path | Requête | Réponse `data` | Statut absence | +|---|---|---|---|---| +| GET | `/health` | — | `{ "status": "healthy" }` | — | +| POST | `/devices` | `{}` | `{ "deviceId": "…32-hex", "token": "paseto…" }` | — | +| GET | `/files` | query `folderId?`, `page?`, `pageSize?`, `sort?` | `FileDto[]` (+ `meta`) | — | +| GET | `/files/:id` | — | `FileDto` | `NOT_FOUND` | +| DELETE | `/files/:id` | — | `{ "id": "…" }` | `NOT_FOUND` | +| GET | `/files/search` | query `q` (obligatoire), `page?`, `pageSize?` | `FileDto[]` (+ `meta`) | — | +| GET | `/files/folders` | — | `FolderDto[]` (racines) | — | +| POST | `/files/upload` | multipart : `file` (uri/name/type), `folderId?` | `FileDto` | `FILE_TOO_LARGE` | +| POST | `/ocr/jobs` | `{ "fileId": "…" }` | `OcrJob` | — | +| GET | `/ocr/jobs/:id` | — | `OcrJob` | `NOT_FOUND` | +| POST | `/sync/ops` | voir §6 | voir §6 | — | +| GET | `/sync/permissions` | query `after?` (cached_at ms) | `ResourcePermission[]` | — | + +### DTOs (copie conforme de `mobile/api/types.ts`) + +```ts +type FileDto = { + id: string; // resource_id 32-hex + name: string; + size: number; + mimeType?: string | null; + folderId?: string | null; // resource_id 32-hex + tags?: string[]; + createdAt?: string; + updatedAt?: string; +}; +type FolderDto = { id: string; name: string; parentId?: string | null }; +type OcrJobStatus = 'queued' | 'processing' | 'done' | 'failed'; +type OcrJob = { id: string; status: OcrJobStatus; text?: string | null; error?: string | null }; +``` + +## 4. Upload + +- Multipart : champ `file` + `folderId?` optionnel. **Le client ne fixe jamais `Content-Type`** (le boundary doit être généré par la plateforme). +- Limite : `MAX_FILE_SIZE_MB` (défaut 50). Dépassement → 413 `{ "error": { "code": "FILE_TOO_LARGE", … } }`. +- Le fichier physique est stocké sous `UPLOAD_DIR` ; la métadonnée est persistée en base et renvoyée en `FileDto`. + +## 5. OCR + +- `POST /ocr/jobs { fileId }` → `OcrJob` immédiat (`status: queued`), traitement **asynchrone**. +- `GET /ocr/jobs/:id` → statut. Le mobile **poll toutes les 3s** jusqu'à `done`/`failed` (`hooks/useUpload.ts`). +- Moteur : **Tesseract en appel système**, langue configurable `OCR_LANG` (défaut `fra+eng`). Un stub qui répond indéfiniment `status: "pending"` est un comportement temporaire acceptable (le client ne casse pas). +- Extraction texte PDF : `ledongthuc/pdf` (déjà en go.mod). + +## 6. Contrat de sync (outbox + snapshot) + +### 6.1 Outbox — `POST /sync/ops` + +```json +{ + "operations": [ + { + "operation_id": 42, // = id client (pending_operations.id) + "ref_type": "resource", // "resource" | "share" | "share_link" + "ref_id": 7, // id local de la ligne share/share_link (sinon null) + "resource_id": "…32-hex", // ressource cible + "resource_type": "folder", // "folder" | "file" + "operation": "create_resource", + "payload": {} + } + ] +} +``` + +- `operation` ∈ `create_resource | update_metadata | delete_resource | move_resource | share | revoke_share | update_share | create_link | revoke_link` (cf. `PendingOperationType` mobile). +- **Idempotence** : contrainte d'unicité serveur `(device_id, operation_id)`. Pour chaque op : si déjà traitée → **no-op** (comptée comme appliquée, les doublons arrivent à cause du backoff/retry). Sinon appliquée si valide. +- **Ordre** : les opérations sont appliquées **séquentiellement**, dans l'ordre du batch. Le serveur **s'arrête à la première erreur non-idempotente** et renvoie l'index atteint — le client reprend à cet index. +- Réponse : `2xx` avec `{ "applied": int, "failed": { "operation_id": int, "code": string, "message": string } | null }` (`applied` = index de la prochaine op à envoyer). +- Côté client, le `pushStatus` (pending/synced/failed) des shares/share_links est **dérivé** de l'état des opérations de l'outbox ; dead-letter après `MAX_PENDING_ATTEMPTS` (= 5). + +### 6.2 Snapshot — `GET /sync/permissions?after=` + +- Renvoie le delta (ou l'ensemble) des permissions effectives pour le device appelant, chacune sous la forme exacte consommée par `canAccess` : + +```ts +type ResourcePermission = { + resource_id: string; // 32-hex + resourceType: 'folder' | 'file'; + effectiveAccess: 'viewer' | 'commenter' | 'editor' | 'owner'; + inherit: boolean; + ownerId: string | null; // device ownership si applicable + sharedById: string | null; + expiresAt: number | null; // ms epoch ; null = jamais + cachedAt: number; // ms epoch — horodatage du snapshot (TTL 24h) + updatedAt: number; +}; +``` + +- **Calcul de `effective_access`** (le serveur est la source de vérité) : + 1. Rang : `viewer = 1 < commenter = 2 < editor = 3 < owner = 4`. + 2. La permission **exacte sur le nœud** est autoritaire (elle n'est pas annulée par son propre `inherit=false`). + 3. Les ancêtres propagent **uniquement si leur relation a `inherit = true`** ; une relation expirée (`expires_at` passé) est ignorée **et ne propage pas**. + 4. `owner_id` == device appelant → `owner` (fallback, quel que soit le niveau remonté). + 5. Le **rang le plus élevé** l'emporte ; sans relation applicable et sans ownership → la ressource n'est pas dans le snapshot. +- **TTL / stale** : après `PERMISSION_TTL_MS` (= 24h) sans reseed, `canAccess` **downgrade en lecture seule** (`viewer`) vers le cache. + +### 6.3 Placements + +- Le statut de placement (`sync_status` mobile : `local` | `cloud` | `local-cloud`) est le reflet du `resource_placements` serveur (statuts V2 : `local_only`, `synced`, `cloud_only`, `pending_upload`, `pending_download`). La matérialisation se fait via l'outbox (`create_resource` → `synced`/`pending_upload` ; suppression physique locale ≠ suppression serveur). + +## 7. Codes d'erreur courants + +`NOT_FOUND`, `NOT_IMPLEMENTED` (501 temporaire sur les routes non construites), `FILE_TOO_LARGE`, `NETWORK_ERROR` (côté client), `HTTP_` (fallback). Le serveur doit répondre 501 `{ "error": { "code": "NOT_IMPLEMENTED", "message": "…" } }` sur toute route encore en queue. \ No newline at end of file